From e16f212c463696cc5f8fd11d50c422ac0db48470 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 9 Nov 2026 11:13:14 -0700 Subject: [PATCH 01/16] Expose canonical native navigation selection Provide SelectionItem state, existing Select actions and selection events for Graph, Quick Chats and canonical sidebar rows through their common root container. Preserve independent worktree selection and retire stale provider references. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../AccessibilityProvider.Native.Tests.cpp | 442 +++++++++++++++++- graphcode-windows/src/Accessibility.zig | 22 +- .../src/AccessibilityProvider.cpp | 191 ++++++-- 3 files changed, 597 insertions(+), 58 deletions(-) diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index 4df18028..f8b9b014 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -8,6 +8,418 @@ #include #include +#ifdef GC_UIA_HEADLESS_SELECTION + +struct SelectionEvent { + IRawElementProviderSimple *provider; + int id; + bool property; + bool before; + bool after; +}; +static std::vector selectionEvents; +static std::vector selectionCommands; +static bool rejectSelectionCommand = false; +static int forbiddenHostCalls = 0; + +static BOOL WINAPI selectionPostMessage(HWND hwnd, UINT message, WPARAM command, LPARAM argument) { + if (hwnd || message != WM_COMMAND || argument) ++forbiddenHostCalls; + if (rejectSelectionCommand) { + SetLastError(ERROR_INVALID_WINDOW_HANDLE); + return FALSE; + } + selectionCommands.push_back(command); + return TRUE; +} +static HRESULT WINAPI selectionAutomationEvent(IRawElementProviderSimple *provider, EVENTID id) { + selectionEvents.push_back({provider, id, false, false, false}); + return S_OK; +} +static HRESULT WINAPI selectionPropertyEvent( + IRawElementProviderSimple *provider, PROPERTYID id, VARIANT before, VARIANT after) { + if (id == UIA_SelectionItemIsSelectedPropertyId) + selectionEvents.push_back({provider, id, true, + before.vt == VT_BOOL && before.boolVal == VARIANT_TRUE, + after.vt == VT_BOOL && after.boolVal == VARIANT_TRUE}); + return S_OK; +} +static HRESULT WINAPI selectionStructureEvent( + IRawElementProviderSimple *, StructureChangeType, int *, int) { return S_OK; } +static HRESULT WINAPI selectionHostProvider(HWND, IRawElementProviderSimple **) { + ++forbiddenHostCalls; + return E_UNEXPECTED; +} +static LRESULT WINAPI selectionReturnProvider(HWND, WPARAM, LPARAM, IRawElementProviderSimple *) { + ++forbiddenHostCalls; + return 0; +} + +// Intercept before including production code, after SDK declarations: no UIA client, +// host registration, message delivery, HWND creation, or managed mock type is involved. +#define PostMessageW selectionPostMessage +#define UiaRaiseAutomationEvent selectionAutomationEvent +#define UiaRaiseAutomationPropertyChangedEvent selectionPropertyEvent +#define UiaRaiseStructureChangedEvent selectionStructureEvent +#define UiaHostProviderFromHwnd selectionHostProvider +#define UiaReturnRawElementProvider selectionReturnProvider +#include "..\..\..\graphcode-windows\src\AccessibilityProvider.cpp" +#undef PostMessageW +#undef UiaRaiseAutomationEvent +#undef UiaRaiseAutomationPropertyChangedEvent +#undef UiaRaiseStructureChangedEvent +#undef UiaHostProviderFromHwnd +#undef UiaReturnRawElementProvider + +static int checks = 0; +static int failures = 0; +static void check(bool condition, const char *description) { + ++checks; + if (!condition) { + ++failures; + std::fprintf(stderr, "FAIL: %s\n", description); + } +} + +template +static T *selectionInterface(IRawElementProviderSimple *provider) { + T *result = nullptr; + if (provider) provider->QueryInterface(__uuidof(T), reinterpret_cast(&result)); + return result; +} + +static IRawElementProviderSimple *findSelectionElement( + IRawElementProviderSimple *provider, const wchar_t *name) { + VARIANT value{}; + if (!provider) return nullptr; + if (provider->GetPropertyValue(UIA_NamePropertyId, &value) != S_OK) return nullptr; + const bool found = value.vt == VT_BSTR && std::wcscmp(value.bstrVal, name) == 0; + VariantClear(&value); + if (found) { + provider->AddRef(); + return provider; + } + auto *fragment = selectionInterface(provider); + if (!fragment) return nullptr; + IRawElementProviderFragment *child = nullptr; + fragment->Navigate(NavigateDirection_FirstChild, &child); + fragment->Release(); + while (child) { + IRawElementProviderSimple *simple = nullptr; + // Fragment and Simple are distinct COM subobjects. + child->QueryInterface(IID_PPV_ARGS(&simple)); + auto *result = simple ? findSelectionElement(simple, name) : nullptr; + if (simple) simple->Release(); + IRawElementProviderFragment *next = nullptr; + if (!result) child->Navigate(NavigateDirection_NextSibling, &next); + child->Release(); + if (result) return result; + child = next; + } + return nullptr; +} + +static bool sameSelectionObject(IUnknown *left, IUnknown *right) { + IUnknown *a = nullptr, *b = nullptr; + if (left) left->QueryInterface(IID_PPV_ARGS(&a)); + if (right) right->QueryInterface(IID_PPV_ARGS(&b)); + const bool same = a && b && a == b; + if (a) a->Release(); + if (b) b->Release(); + return same; +} + +static bool selectedDestination(ISelectionProvider *container, IRawElementProviderSimple *expected) { + SAFEARRAY *values = nullptr; + if (container->GetSelection(&values) != S_OK || !values) return false; + LONG first = 0, last = -1; + SafeArrayGetLBound(values, 1, &first); + SafeArrayGetUBound(values, 1, &last); + bool result = expected ? last - first == 0 : last < first; + if (expected && result) { + IUnknown *item = nullptr; + result = SafeArrayGetElement(values, &first, &item) == S_OK && + sameSelectionObject(item, expected); + if (item) item->Release(); + } + SafeArrayDestroy(values); + return result; +} + +static bool selectionEvent(IRawElementProviderSimple *provider, int id, + bool property = false, bool before = false, bool after = false) { + return std::count_if(selectionEvents.begin(), selectionEvents.end(), + [&](const SelectionEvent &event) { + return event.provider == provider && event.id == id && event.property == property && + (!property || (event.before == before && event.after == after)); + }) == 1; +} + +static bool selectionBoolProperty(IRawElementProviderSimple *provider, PROPERTYID id, bool expected) { + VARIANT value{}; + const bool result = provider->GetPropertyValue(id, &value) == S_OK && value.vt == VT_BOOL && + value.boolVal == (expected ? VARIANT_TRUE : VARIANT_FALSE); + VariantClear(&value); + return result; +} + +int main() { + auto *root = gc_uia_create(nullptr); + const char *ids[] = { + "overview-destination:graph", "quick-chats-destination:quick-chats", + "open-project:test", "loop:test:node", "quick-chat-row:chat", + "needs-you-row:test:node", "quick-chats-header:quick-chats", + "quick-chat-workspace:chat", "worktree:test"}; + const char *names[] = {"Graph", "Quick Chats", "Project", "Loop", "Chat", + "Needs you alias", "Chats alias", "Workspace alias", "Worktree"}; + const int parents[] = {1, 1, 1, 2, 1, 1, 1, 4, 3}; + const int eligible[] = {0, 0, 0, 0, 0, 0, 0, 0, 1}; + const int actions[] = {1, 1, 1, 1, 1, 1, 1, 0, 0}; + int selected[] = {1, 0, 0, 0, 0, 0, 0, 0, 1}; + auto publish = [&](int count = 9) { + check(gc_uia_update(root, "Ready", ids, names, parents, selected, eligible, + actions, nullptr, count, 0, 1, 1, 0, 0, 0) == S_OK, + "headless publication succeeds"); + }; + publish(); + auto *projects = findSelectionElement(root, L"Projects"); + auto *loops = findSelectionElement(root, L"Loops"); + auto *worktrees = findSelectionElement(root, L"Worktrees"); + auto *container = selectionInterface(root); + check(container != nullptr, "common ancestor exposes canonical Selection interface"); + IUnknown *containerPattern = nullptr; + check(root->GetPatternProvider(UIA_SelectionPatternId, &containerPattern) == S_OK && + sameSelectionObject(containerPattern, container), + "common ancestor pattern and QueryInterface share identity"); + if (containerPattern) containerPattern->Release(); + check(selectionBoolProperty(root, UIA_IsSelectionPatternAvailablePropertyId, true), + "common ancestor advertises Selection pattern availability"); + auto *projectContainer = selectionInterface(projects); + check(projectContainer == nullptr, "projects does not expose a competing selection scope"); + if (projectContainer) projectContainer->Release(); + check(selectionBoolProperty(projects, UIA_IsSelectionPatternAvailablePropertyId, false), + "projects does not advertise an independent Selection pattern"); + BOOL multiple = TRUE; + check(container && container->get_CanSelectMultiple(&multiple) == S_OK && !multiple, + "navigation container is single select"); + BOOL required = FALSE; + check(container && container->get_IsSelectionRequired(&required) == S_OK && required, + "navigation selection cannot be cleared independently"); + auto *loopContainer = selectionInterface(loops); + check(loopContainer == nullptr, "loops does not expose a competing selection scope"); + if (loopContainer) loopContainer->Release(); + auto *worktreeContainer = selectionInterface(worktrees); + check(worktreeContainer && worktreeContainer->get_CanSelectMultiple(&multiple) == S_OK && multiple, + "worktree multiselection remains independent"); + auto *worktree = findSelectionElement(root, L"Worktree"); + auto *worktreeItem = selectionInterface(worktree); + IRawElementProviderSimple *worktreeOwner = nullptr; + check(worktreeItem && worktreeItem->get_SelectionContainer(&worktreeOwner) == S_OK && + sameSelectionObject(worktreeOwner, worktrees), + "worktree item retains its own selection container"); + if (worktreeOwner) worktreeOwner->Release(); + check(worktreeContainer && selectedDestination(worktreeContainer, worktree), + "worktree GetSelection retains its independently selected row"); + check(worktreeItem && worktreeItem->RemoveFromSelection() == S_OK && + selectedDestination(worktreeContainer, nullptr) && + worktreeItem->AddToSelection() == S_OK && + selectedDestination(worktreeContainer, worktree), + "worktree add/remove selection behavior is preserved"); + if (worktreeItem) worktreeItem->Release(); + if (worktree) worktree->Release(); + + IRawElementProviderSimple *destinations[5]{}; + ISelectionItemProvider *items[5]{}; + for (int i = 0; i < 5; ++i) { + destinations[i] = findSelectionElement(root, wide(names[i]).c_str()); + check(destinations[i] != nullptr, "canonical navigation destination exists"); + if (!destinations[i]) continue; + items[i] = selectionInterface(destinations[i]); + check(items[i] != nullptr, "destination QueryInterface exposes SelectionItem"); + IUnknown *pattern = nullptr; + check(destinations[i]->GetPatternProvider(UIA_SelectionItemPatternId, &pattern) == S_OK && + pattern && sameSelectionObject(pattern, items[i]), + "pattern and QueryInterface share COM identity"); + if (pattern) pattern->Release(); + check(selectionBoolProperty(destinations[i], UIA_IsSelectionItemPatternAvailablePropertyId, true), + "destination advertises SelectionItem pattern availability"); + check(selectionBoolProperty(destinations[i], UIA_SelectionItemIsSelectedPropertyId, i == 0), + "destination selection property matches SelectionItem state"); + if (!items[i]) continue; + BOOL value = FALSE; + check(items[i]->get_IsSelected(&value) == S_OK && value == (i == 0 ? TRUE : FALSE), + "initial IsSelected matches publication"); + IRawElementProviderSimple *owner = nullptr; + check(items[i]->get_SelectionContainer(&owner) == S_OK && + sameSelectionObject(owner, root), + "all destinations share one navigation container"); + if (owner) owner->Release(); + check(items[i]->get_IsSelected(nullptr) == E_POINTER && + items[i]->get_SelectionContainer(nullptr) == E_POINTER, + "selection item rejects null output pointers"); + const size_t commands = selectionCommands.size(); + const size_t events = selectionEvents.size(); + check(items[i]->Select() == S_OK && selectionCommands.size() == commands + 1, + "Select dispatches exactly one existing action"); + const WPARAM expected = i < 2 ? static_cast(20 + i) + : kDynamicInvokeTag | (hashPath(ids[i]) & kRowPayloadMask); + check(selectionCommands.size() == commands + 1 && selectionCommands.back() == expected, + "Select uses the unchanged fixed/dynamic Invoke command"); + check(items[i]->get_IsSelected(&value) == S_OK && value == (i == 0 ? TRUE : FALSE) && + selectionEvents.size() == events, + "Select does not optimistically change published navigation state"); + check(items[i]->RemoveFromSelection() == UIA_E_INVALIDOPERATION, + "navigation cannot be deselected independently"); + const HRESULT addResult = i == 0 ? S_OK : static_cast(UIA_E_INVALIDOPERATION); + check(items[i]->AddToSelection() == addResult, + "AddToSelection cannot replace another destination"); + check(selectionCommands.size() == commands + 1, + "selection add/remove does not post an extra action"); + rejectSelectionCommand = true; + check(items[i]->Select() == HRESULT_FROM_WIN32(ERROR_INVALID_WINDOW_HANDLE) && + selectionCommands.size() == commands + 1 && selectionEvents.size() == events, + "fixed and dynamic Select report dispatch failure"); + rejectSelectionCommand = false; + } + check(container && selectedDestination(container, destinations[0]), + "GetSelection includes fixed Graph and excludes worktree state"); + for (int i = 5; i < 8; ++i) { + auto *alias = findSelectionElement(root, wide(names[i]).c_str()); + check(alias != nullptr, "duplicate alias remains in accessibility tree"); + if (!alias) continue; + auto *item = selectionInterface(alias); + IUnknown *pattern = nullptr; + check(!item && alias->GetPatternProvider(UIA_SelectionItemPatternId, &pattern) == S_FALSE && + !pattern, + "duplicate alias does not expose selection"); + if (item) item->Release(); + if (pattern) pattern->Release(); + check(selectionBoolProperty(alias, UIA_IsSelectionItemPatternAvailablePropertyId, false), + "alias does not advertise SelectionItem pattern availability"); + alias->Release(); + } + + for (int i = 1; i < 5; ++i) { + selectionEvents.clear(); + selected[i - 1] = 0; + selected[i] = 1; + selected[5] = i == 3; + selected[6] = i == 1; + selected[7] = i == 4; + publish(); + check(container && selectedDestination(container, destinations[i]), + "GetSelection returns exactly the canonical selected destination"); + for (int j = 0; j < 5; ++j) { + BOOL value = FALSE; + check(items[j] && items[j]->get_IsSelected(&value) == S_OK && + value == (i == j ? TRUE : FALSE), + "all navigation IsSelected values agree after transition"); + } + check(selectionEvent(destinations[i], UIA_SelectionItem_ElementSelectedEventId), + "new destination raises ElementSelected"); + check(selectionEvent(destinations[i - 1], UIA_SelectionItem_ElementRemovedFromSelectionEventId), + "old destination raises ElementRemovedFromSelection"); + check(selectionEvent(destinations[i], UIA_SelectionItemIsSelectedPropertyId, true, false, true) && + selectionEvent(destinations[i - 1], UIA_SelectionItemIsSelectedPropertyId, true, true, false), + "selection boolean changes raise property events"); + check(selectionEvents.size() == 4, "navigation transition emits only canonical selection events"); + selectionEvents.clear(); + publish(); + check(selectionEvents.empty(), "unchanged publication emits no selection event"); + } + + selectionEvents.clear(); + selected[0] = 1; + check(gc_uia_update(root, "Conflicting", ids, names, parents, selected, eligible, + actions, nullptr, 9, 0, 1, 1, 0, 0, 0) == E_INVALIDARG, + "conflicting canonical selection is rejected rather than silently normalized"); + check(container && selectedDestination(container, destinations[4]) && selectionEvents.empty(), + "rejected snapshot leaves selection and events unchanged"); + selected[0] = 0; + auto *oldChat = items[4]; + if (oldChat) oldChat->AddRef(); + selectionEvents.clear(); + publish(4); + BOOL value = TRUE; + check(oldChat && oldChat->get_IsSelected(&value) == UIA_E_ELEMENTNOTAVAILABLE, + "removed selected row reference becomes unavailable"); + check(container && selectedDestination(container, nullptr), + "removed selected destination is absent from GetSelection"); + check(selectionEvent(root, UIA_Selection_InvalidatedEventId), + "retiring selected row invalidates canonical selection"); + publish(); + check(oldChat && oldChat->get_IsSelected(&value) == UIA_E_ELEMENTNOTAVAILABLE, + "republishing identity does not revive retired SelectionItem"); + check(oldChat && oldChat->Select() == UIA_E_ELEMENTNOTAVAILABLE, + "retired SelectionItem cannot dispatch actions"); + auto *newChat = findSelectionElement(root, L"Chat"); + auto *newChatItem = newChat ? selectionInterface(newChat) : nullptr; + check(newChatItem && newChatItem->get_IsSelected(&value) == S_OK && value, + "republished destination has a fresh working SelectionItem"); + if (newChatItem) newChatItem->Release(); + if (newChat) newChat->Release(); + selected[4] = 0; + selected[0] = 1; + publish(); + selectionEvents.clear(); + publish(0); + check(items[0] && items[0]->get_IsSelected(&value) == S_OK && !value, + "unpublished fixed destination clears selected state"); + check(container && selectedDestination(container, nullptr), + "empty publication has no selected destination"); + check(selectionEvent(destinations[0], UIA_SelectionItem_ElementRemovedFromSelectionEventId) && + selectionEvent(destinations[0], UIA_SelectionItemIsSelectedPropertyId, true, true, false), + "unpublishing selected fixed destination raises removal and property events"); + publish(); + SAFEARRAY *retainedSelection = nullptr; + check(container && container->GetSelection(&retainedSelection) == S_OK && retainedSelection, + "selection array retains its selected provider independently"); + gc_uia_release(root); + check(gc_uia_update(root, "Late", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, + nullptr, 0, 0, 1, 0, 0, 0, 0) == UIA_E_ELEMENTNOTAVAILABLE, + "publication through a retained shut-down root is rejected"); + check(container && container->GetSelection(nullptr) == E_POINTER, + "container null output remains E_POINTER after shutdown"); + SAFEARRAY *none = nullptr; + check(container && container->GetSelection(&none) == UIA_E_ELEMENTNOTAVAILABLE && !none, + "retained container becomes unavailable at shutdown"); + check(container && container->get_CanSelectMultiple(&value) == UIA_E_ELEMENTNOTAVAILABLE, + "retained selection metadata becomes unavailable at shutdown"); + check(container && container->get_IsSelectionRequired(&value) == UIA_E_ELEMENTNOTAVAILABLE, + "retained required-selection metadata becomes unavailable at shutdown"); + IUnknown *retainedUnknown = nullptr; + LONG first = 0; + check(retainedSelection && SafeArrayGetElement(retainedSelection, &first, &retainedUnknown) == S_OK && + sameSelectionObject(retainedUnknown, destinations[0]), + "retained SAFEARRAY still owns the original COM identity after shutdown"); + if (retainedUnknown) retainedUnknown->Release(); + if (retainedSelection) SafeArrayDestroy(retainedSelection); + for (int i = 0; i < 5; ++i) { + IRawElementProviderSimple *owner = nullptr; + IUnknown *unavailablePattern = nullptr; + check(items[i] && items[i]->get_IsSelected(&value) == UIA_E_ELEMENTNOTAVAILABLE && + items[i]->get_SelectionContainer(&owner) == UIA_E_ELEMENTNOTAVAILABLE && !owner && + items[i]->Select() == UIA_E_ELEMENTNOTAVAILABLE && + items[i]->AddToSelection() == UIA_E_ELEMENTNOTAVAILABLE && + items[i]->RemoveFromSelection() == UIA_E_ELEMENTNOTAVAILABLE && + destinations[i]->GetPatternProvider(UIA_SelectionItemPatternId, &unavailablePattern) == + UIA_E_ELEMENTNOTAVAILABLE && !unavailablePattern, + "retained destination selection methods are unavailable after shutdown"); + if (items[i]) items[i]->Release(); + if (destinations[i]) destinations[i]->Release(); + } + if (oldChat) oldChat->Release(); + if (container) container->Release(); + if (worktreeContainer) worktreeContainer->Release(); + if (projects) projects->Release(); + if (loops) loops->Release(); + if (worktrees) worktrees->Release(); + check(forbiddenHostCalls == 0, "no native UIA host or real window/message binding was used"); + std::printf("Headless navigation selection: %d checks, %d failures\n", checks, failures); + return failures || checks == 0 ? 1 : 0; +} + +#else + extern "C" IRawElementProviderSimple *gc_uia_create(HWND hwnd); extern "C" void gc_uia_release(IRawElementProviderSimple *provider); extern "C" HRESULT gc_uia_set_header_focus(IRawElementProviderSimple *provider, const char *identity); @@ -16,7 +428,9 @@ extern "C" HRESULT gc_uia_update(IRawElementProviderSimple *provider, const char const int *parents, const int *selected, const int *eligible, const int *invokable, const int *bounds, int count, int allow_reclaim, - int confirm_each_reclaim); + int confirm_each_reclaim, int worktrees_available = 0, + int worktree_dialog_open = 0, int worktree_row_selected = 0, + int worktree_busy = 0); static int failures = 0; static int checks = 0; @@ -52,10 +466,6 @@ static void checkName(IRawElementProviderFragment *fragment, const wchar_t *expe simple->Release(); } -typedef HRESULT (*FullUpdate)(IRawElementProviderSimple *, const char *, const char **, - const char **, const int *, const int *, const int *, - const int *, const int *, int, int, int, int, int, int, int); - static std::wstring stringProperty(IRawElementProviderFragment *fragment, PROPERTYID property) { std::wstring result; IRawElementProviderSimple *simple = nullptr; @@ -143,7 +553,7 @@ static void nativeRootGeometryChecks() { const int parents[] = {2}; const int flags[] = {0}; const int row_bounds[] = {32, 120, 192, 160}; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Geometry", identities, names, parents, flags, flags, flags, row_bounds, 1, 0, 1, 0, 0, 0, 0) == S_OK, "root geometry descendant publication succeeds"); @@ -244,7 +654,7 @@ static void nativeHitTestChecks(IRawElementProviderSimple *provider, HWND hwnd) 12, 480, 232, 520, // row published (wrongly) under the banner: the banner must win 8, 500, 212, 550, // update banner }; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Hit testing", identities, names, parents, none, none, actions, bounds, 10, 0, 1, 0, 0, 0, 0) == S_OK, "hit-testing publication succeeds"); @@ -307,7 +717,7 @@ static void nativeSidebarGeometryChecks(IRawElementProviderSimple *provider, HWN const int none[] = {0, 0, 0, 0, 0}; const int actions[] = {1, 1, 1, 1, 1}; const int bounds[] = {8, 500, 212, 550, 12, 150, 232, 176, 0, 0, 0, 0, 0, 0, 0, 0, 12, 200, 232, 226}; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Geometry", identities, names, parents, none, none, actions, bounds, 5, 0, 1, 0, 0, 0, 0) == S_OK, "sidebar geometry publication succeeds"); @@ -442,7 +852,7 @@ static void nativeHeaderOverflowChecks(IRawElementProviderSimple *provider, HWND const int eligible[] = {1}; const int invokable_flags[] = {1}; const int bounds[] = {444, 5, 480, 29}; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Header overflow", identities, names, parents, none, eligible, invokable_flags, bounds, 1, 0, 1, 0, 0, 0, 0) == S_OK, "header overflow publishes"); @@ -518,7 +928,7 @@ static void nativeBannerInvokeChecks(IRawElementProviderSimple *provider, HWND h const int none[] = {0}; const int actions[] = {1}; const int bounds[] = {8, 500, 212, 550}; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Banner", identities, names, parents, none, none, actions, bounds, 1, 0, 1, 0, 0, 0, 0) == S_OK, "banner publication succeeds"); @@ -553,7 +963,7 @@ static void nativeUnwiredRecoveryChecks(IRawElementProviderSimple *provider, HWN const int none[] = {0, 0}; const int actions[] = {1, 1}; const int bounds[] = {300, 120, 373, 140, 379, 120, 465, 140}; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Unwired recovery", identities, names, parents, none, none, actions, bounds, 2, 0, 1, 0, 0, 0, 0) == S_OK, "unwired recovery publication succeeds"); @@ -607,7 +1017,7 @@ static void nativeCycleHierarchyChecks(IRawElementProviderSimple *provider, HWND 412, 151, 478, 168, 261, 175, 478, 188, }; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Cycle hierarchy", identities, names, parents, none, none, actions, bounds, 2, 0, 1, 0, 0, 0, 0) == S_OK, "cycle hierarchy publication succeeds"); @@ -661,7 +1071,7 @@ static void nativeQuickChatRestartChecks(IRawElementProviderSimple *provider, HW const int none[] = {0}; const int actions[] = {1}; const int bounds[] = {732, 44, 864, 70}; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Quick Chat", identities, names, parents, none, none, actions, bounds, 1, 0, 1, 0, 0, 0, 0) == S_OK, "Quick Chat restart publication succeeds"); @@ -719,7 +1129,7 @@ static void nativeQuickChatWorkspaceBarChecks(IRawElementProviderSimple *provide const int none[] = {0}; const int actions[] = {0}; const int bounds[] = {0, 0, 0, 0}; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Quick Chat", identities, names, parents, none, none, actions, bounds, 1, 0, 1, 0, 0, 0, 0) == S_OK, "Quick Chat workspace bar publication succeeds"); @@ -753,7 +1163,7 @@ static void nativeScrollChecks(IRawElementProviderSimple *provider, HWND hwnd, // Bit 0 invokable, bit 1 scrolls with the sidebar. const int actions[] = {3, 3, 1, 1}; const int bounds[] = {0, 0, 0, 0, 12, 150, 232, 176, 100, 10, 200, 40, 8, 500, 212, 550}; - auto update = reinterpret_cast(&gc_uia_update); + auto update = &gc_uia_update; check(update(provider, "Scroll", identities, names, parents, none, none, actions, bounds, 4, 0, 1, 0, 0, 0, 0) == S_OK, "scroll publication succeeds"); @@ -1008,3 +1418,5 @@ int main() { std::printf("Native accessibility provider: %d checks, %d failures\n", checks, failures); return failures || checks == 0 ? 1 : 0; } + +#endif diff --git a/graphcode-windows/src/Accessibility.zig b/graphcode-windows/src/Accessibility.zig index eacc2b47..b4a0655b 100644 --- a/graphcode-windows/src/Accessibility.zig +++ b/graphcode-windows/src/Accessibility.zig @@ -361,9 +361,9 @@ pub const Provider = struct { pub fn defaultContract(allocator: std.mem.Allocator) !Provider { var provider = Provider.init(allocator); errdefer provider.deinit(); - const window = try provider.add(.{ .id = "graphcode-root", .name = "GraphCode UIA Root", .role = .window }); - const projects = try provider.add(.{ .id = "projects", .name = "Projects", .role = .list, .parent = window, .focusable = true, .patterns = &.{.selection} }); - _ = try provider.add(.{ .id = "loops", .name = "Loops", .role = .list, .parent = window, .focusable = true, .patterns = &.{.selection} }); + const window = try provider.add(.{ .id = "graphcode-root", .name = "GraphCode UIA Root", .role = .window, .patterns = &.{.selection} }); + const projects = try provider.add(.{ .id = "projects", .name = "Projects", .role = .list, .parent = window, .focusable = true }); + _ = try provider.add(.{ .id = "loops", .name = "Loops", .role = .list, .parent = window, .focusable = true }); _ = try provider.add(.{ .id = "worktrees", .name = "Worktrees", .role = .list, .parent = window, .focusable = true, .patterns = &.{.selection} }); const graph = try provider.add(.{ .id = "graph", .name = "Graph", .role = .navigation, .parent = window }); const menu = try provider.add(.{ .id = "actions", .name = "Actions", .role = .menu, .parent = window }); @@ -375,8 +375,8 @@ pub fn defaultContract(allocator: std.mem.Allocator) !Provider { _ = try provider.add(.{ .id = "save-worktree-policy", .name = "Save worktree policy", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "allow-reclaim", .name = "Allow reclaim", .role = .checkbox, .parent = menu, .patterns = &.{ .invoke, .toggle } }); _ = try provider.add(.{ .id = "confirm-each-reclaim", .name = "Confirm each reclaim", .role = .checkbox, .parent = menu, .patterns = &.{ .invoke, .toggle } }); - _ = try provider.add(.{ .id = "overview-destination", .name = "Graph", .role = .button, .parent = projects, .focusable = true, .patterns = &.{.invoke} }); - _ = try provider.add(.{ .id = "quick-chats-destination", .name = "Quick Chats", .role = .button, .parent = projects, .focusable = true, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "overview-destination", .name = "Graph", .role = .list_item, .parent = projects, .focusable = true, .patterns = &.{ .invoke, .selection_item } }); + _ = try provider.add(.{ .id = "quick-chats-destination", .name = "Quick Chats", .role = .list_item, .parent = projects, .focusable = true, .patterns = &.{ .invoke, .selection_item } }); _ = try provider.add(.{ .id = "canvas-primary-action", .name = "New Node or Chat", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "zoom-out", .name = "Zoom out", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "actual-size", .name = "Actual size", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); @@ -433,6 +433,9 @@ test "UIA contract exposes named roles patterns and deterministic focus order" { var provider = try defaultContract(std.testing.allocator); defer provider.deinit(); const projects = provider.indexOfId("projects").?; + const root = provider.indexOfId("graphcode-root").?; + const overview = provider.indexOfId("overview-destination").?; + const chats = provider.indexOfId("quick-chats-destination").?; const loops = provider.indexOfId("loops").?; const worktrees = provider.indexOfId("worktrees").?; const graph = provider.indexOfId("graph").?; @@ -441,7 +444,14 @@ test "UIA contract exposes named roles patterns and deterministic focus order" { const workspace_rename = provider.indexOfId("workspace-rename").?; const workspace_delete = provider.indexOfId("workspace-delete").?; try std.testing.expectEqual(Role.list, provider.elements.items[projects].role); - try std.testing.expect(provider.hasPattern(projects, .selection)); + try std.testing.expect(provider.hasPattern(root, .selection)); + try std.testing.expect(!provider.hasPattern(projects, .selection)); + try std.testing.expect(!provider.hasPattern(loops, .selection)); + for ([_]usize{ overview, chats }) |destination| { + try std.testing.expectEqual(Role.list_item, provider.elements.items[destination].role); + try std.testing.expect(provider.hasPattern(destination, .invoke)); + try std.testing.expect(provider.hasPattern(destination, .selection_item)); + } try std.testing.expectEqual(Role.navigation, provider.elements.items[graph].role); try std.testing.expectEqual(Role.menu, provider.elements.items[workspaces].role); try std.testing.expect(provider.hasPattern(workspaces, .selection)); diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index 214085a0..acede6b1 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -68,6 +68,19 @@ struct Row { bool scrolls = false; RECT bounds{}; }; +static bool isNavigationRow(const Row &row) { + return (row.parent == 1 && + (row.identity.rfind("project:", 0) == 0 || + row.identity.rfind("open-project:", 0) == 0 || + row.identity.rfind("quick-chat-row:", 0) == 0)) || + (row.parent == 2 && row.identity.rfind("loop:", 0) == 0); +} +static bool isSelectionRow(const Row &row) { + if (row.parent == 1 || row.parent == 2) return isNavigationRow(row); + return !isHeader(row.identity) && + row.identity.rfind("workspace-toolbar:", 0) != 0 && + row.identity.rfind("quick-chat-workspace:", 0) != 0; +} struct State { std::mutex mutex; HWND hwnd{}; @@ -97,6 +110,7 @@ struct State { // below. Until the app reports them the fixed elements use placeholder rects. RECT destination_bounds[2]{}; bool has_destination_bounds[2]{}; + bool destination_selected[2]{}; // The sidebar's single scroll viewport, delivered through gc_uia_set_sidebar_scroll: // current offset, maximum offset and visible height, in the same units. int scroll_offset = 0; @@ -172,8 +186,7 @@ class Node final : public IRawElementProviderSimple, *out = static_cast(this); else if (iid == __uuidof(IInvokeProvider) && supportsInvoke()) *out = static_cast(this); - else if (iid == __uuidof(ISelectionProvider) && - ((id_ >= 1 && id_ <= 4) || id_ == 21)) + else if (iid == __uuidof(ISelectionProvider) && supportsSelection()) *out = static_cast(this); else if (iid == __uuidof(ISelectionItemProvider) && supportsSelectionItem()) *out = static_cast(this); @@ -208,10 +221,13 @@ class Node final : public IRawElementProviderSimple, HRESULT STDMETHODCALLTYPE GetPatternProvider(PATTERNID id, IUnknown **value) override { if (!value) return E_POINTER; *value = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + } if (id == UIA_InvokePatternId && supportsInvoke()) *value = static_cast(this); - else if (id == UIA_SelectionPatternId && - ((id_ >= 1 && id_ <= 4) || id_ == 21)) + else if (id == UIA_SelectionPatternId && supportsSelection()) *value = static_cast(this); else if (id == UIA_SelectionItemPatternId && supportsSelectionItem()) *value = static_cast(this); @@ -236,7 +252,17 @@ class Node final : public IRawElementProviderSimple, { std::lock_guard lock(state_->mutex); if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; - if (property == UIA_NamePropertyId) { + if (property == UIA_IsSelectionItemPatternAvailablePropertyId) { + bool_value = supportsSelectionItemLocked(); + kind = kBool; + } else if (property == UIA_IsSelectionPatternAvailablePropertyId) { + bool_value = supportsSelection(); + kind = kBool; + } else if (property == UIA_SelectionItemIsSelectedPropertyId && + supportsSelectionItemLocked()) { + bool_value = isSelectedLocked(); + kind = kBool; + } else if (property == UIA_NamePropertyId) { string_value = nameLocked(); kind = kString; } else if (property == UIA_AutomationIdPropertyId) { @@ -563,19 +589,18 @@ class Node final : public IRawElementProviderSimple, } HRESULT STDMETHODCALLTYPE Invoke() override { - if (!supportsInvoke()) return UIA_E_ELEMENTNOTENABLED; HWND hwnd = nullptr; { std::lock_guard lock(state_->mutex); if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + if (!supportsInvokeLocked()) return UIA_E_ELEMENTNOTENABLED; if (!worktreeFixedEnabledLocked(id_)) return UIA_E_ELEMENTNOTENABLED; hwnd = state_->hwnd; } if (isRowKey(id_)) { const WPARAM command = kDynamicInvokeTag | (static_cast(id_) & kRowPayloadMask); - PostMessageW(hwnd, WM_COMMAND, command, 0); - return S_OK; + return postCommand(hwnd, command); } const UINT command = id_ == 7 ? 6 : id_ == 8 ? 7 : id_ == 9 ? 8 : id_ == 10 ? 9 : id_ == 11 ? 10 : id_ == 12 ? 12 : @@ -583,32 +608,41 @@ class Node final : public IRawElementProviderSimple, id_ == 16 ? 22 : id_ == 17 ? 23 : id_ == 18 ? 24 : id_ == 19 ? 25 : id_ == 20 ? 26 : id_ == 22 ? 27 : id_ == 23 ? 28 : 29; - PostMessageW(hwnd, WM_COMMAND, command, 0); - return S_OK; + return postCommand(hwnd, command); } HRESULT STDMETHODCALLTYPE get_CanSelectMultiple(BOOL *value) override { if (!value) return E_POINTER; - *value = TRUE; + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + *value = id_ == 0 ? FALSE : TRUE; return S_OK; } HRESULT STDMETHODCALLTYPE get_IsSelectionRequired(BOOL *value) override { if (!value) return E_POINTER; - *value = FALSE; + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + *value = id_ == 0 ? TRUE : FALSE; return S_OK; } HRESULT STDMETHODCALLTYPE GetSelection(SAFEARRAY **value) override { if (!value) return E_POINTER; + *value = nullptr; std::vector selected; { std::lock_guard lock(state_->mutex); - if (!isAvailableLocked() || - !((id_ >= 1 && id_ <= 4) || id_ == 21)) { - *value = nullptr; + if (!isAvailableLocked() || !supportsSelection()) { return UIA_E_ELEMENTNOTAVAILABLE; } + if (id_ == 0) { + for (int slot = 0; slot < 2; ++slot) + if (state_->destination_selected[slot]) + if (Node *node = retainElementLocked(14 + slot)) selected.push_back(node); + } for (int64_t id : state_->row_order) { const auto row = state_->rows.find(id); - if (row != state_->rows.end() && row->second.parent == id_ && row->second.selected) { + if (row != state_->rows.end() && isSelectionRow(row->second) && + (isNavigationRow(row->second) ? id_ == 0 : row->second.parent == id_) && + row->second.selected) { if (Node *node = retainElementLocked(id)) selected.push_back(node); } } @@ -616,14 +650,19 @@ class Node final : public IRawElementProviderSimple, *value = SafeArrayCreateVector(VT_UNKNOWN, 0, static_cast(selected.size())); if (!*value) { for (Node *node : selected) node->Release(); - return selected.empty() ? S_OK : E_OUTOFMEMORY; + return E_OUTOFMEMORY; } + HRESULT result = S_OK; for (LONG index = 0; index < static_cast(selected.size()); ++index) { IUnknown *unknown = static_cast(selected[static_cast(index)]); - SafeArrayPutElement(*value, &index, unknown); + if (SUCCEEDED(result)) result = SafeArrayPutElement(*value, &index, unknown); selected[static_cast(index)]->Release(); } - return S_OK; + if (FAILED(result)) { + SafeArrayDestroy(*value); + *value = nullptr; + } + return result; } HRESULT STDMETHODCALLTYPE Select() override { return setSelected(kSelect); } HRESULT STDMETHODCALLTYPE AddToSelection() override { return setSelected(kAdd); } @@ -631,9 +670,10 @@ class Node final : public IRawElementProviderSimple, HRESULT STDMETHODCALLTYPE get_IsSelected(BOOL *value) override { if (!value) return E_POINTER; std::lock_guard lock(state_->mutex); - if (!isAvailableLocked() || !isRowKey(id_)) + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; - *value = state_->rows.at(id_).selected ? TRUE : FALSE; + if (!supportsSelectionItemLocked()) return UIA_E_INVALIDOPERATION; + *value = isSelectedLocked() ? TRUE : FALSE; return S_OK; } HRESULT STDMETHODCALLTYPE get_SelectionContainer(IRawElementProviderSimple **value) override { @@ -642,9 +682,10 @@ class Node final : public IRawElementProviderSimple, Node *container = nullptr; { std::lock_guard lock(state_->mutex); - if (!isAvailableLocked() || !isRowKey(id_)) + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; - container = retainElementLocked(state_->rows.at(id_).parent); + if (!supportsSelectionItemLocked()) return UIA_E_INVALIDOPERATION; + container = retainElementLocked(selectionContainerLocked()); } if (!container) return UIA_E_ELEMENTNOTAVAILABLE; *value = static_cast(container); @@ -820,7 +861,7 @@ class Node final : public IRawElementProviderSimple, return S_OK; } - void update(const char *status, const char **identities, const char **names, + HRESULT update(const char *status, const char **identities, const char **names, const int *parents, const int *selected, const int *eligible, const int *invokable, const int *bounds, int count, bool allow_reclaim, bool confirm_each_reclaim, bool worktrees_available, @@ -841,7 +882,7 @@ class Node final : public IRawElementProviderSimple, bool confirm_reclaim_changed = false; { std::lock_guard lock(state_->mutex); - if (!state_->active) return; + if (!state_->active) return UIA_E_ELEMENTNOTAVAILABLE; old_status = state_->status; state_->status = wide(status); new_status = state_->status; @@ -857,6 +898,10 @@ class Node final : public IRawElementProviderSimple, std::unordered_map next_rows; std::vector next_order; + const bool old_destination_selected[] = { + state_->destination_selected[0], state_->destination_selected[1]}; + state_->destination_selected[0] = false; + state_->destination_selected[1] = false; state_->has_destination_bounds[0] = false; state_->has_destination_bounds[1] = false; for (int index = 0; index < count; ++index) { @@ -869,6 +914,7 @@ class Node final : public IRawElementProviderSimple, bounds[index * 4 + 2], bounds[index * 4 + 3]} : RECT{}; state_->has_destination_bounds[destination] = true; + state_->destination_selected[destination] = selected && selected[index] != 0; continue; } int64_t key = rowKeyForIdentityLocked(identity, next_rows); @@ -896,9 +942,13 @@ class Node final : public IRawElementProviderSimple, std::vector added_selection; std::vector removed_selection; size_t next_selected_count[5]{}; + next_selected_count[0] = (state_->destination_selected[0] ? 1 : 0) + + (state_->destination_selected[1] ? 1 : 0); for (const auto &item : next_rows) { - if (item.second.parent < 1 || item.second.parent > 4) continue; - if (item.second.selected) ++next_selected_count[item.second.parent]; + if (item.second.parent < 1 || item.second.parent > 4 || + !isSelectionRow(item.second)) continue; + const int parent = isNavigationRow(item.second) ? 0 : item.second.parent; + if (item.second.selected) ++next_selected_count[parent]; const auto old = state_->rows.find(item.first); if (item.second.selected && (old == state_->rows.end() || !old->second.selected)) { @@ -906,11 +956,17 @@ class Node final : public IRawElementProviderSimple, } } for (const auto &item : state_->rows) { - if (item.second.parent < 1 || item.second.parent > 4) continue; + if (item.second.parent < 1 || item.second.parent > 4 || + !isSelectionRow(item.second)) continue; const auto next = next_rows.find(item.first); if (item.second.selected && (next == next_rows.end() || !next->second.selected)) { - removed_selection.push_back(item.first); + if (next != next_rows.end()) { + removed_selection.push_back(item.first); + } else if (Node *container = retainElementLocked( + isNavigationRow(item.second) ? 0 : item.second.parent)) { + selection_events.emplace_back(container, UIA_Selection_InvalidatedEventId); + } } } for (const auto &item : state_->rows) { @@ -926,7 +982,8 @@ class Node final : public IRawElementProviderSimple, for (int64_t id : added_selection) { const auto row = state_->rows.find(id); if (row == state_->rows.end()) continue; - const EVENTID event = next_selected_count[row->second.parent] == 1 + const int parent = isNavigationRow(row->second) ? 0 : row->second.parent; + const EVENTID event = next_selected_count[parent] == 1 ? UIA_SelectionItem_ElementSelectedEventId : UIA_SelectionItem_ElementAddedToSelectionEventId; if (Node *node = retainElementLocked(id)) { @@ -938,6 +995,13 @@ class Node final : public IRawElementProviderSimple, selection_events.emplace_back(node, UIA_SelectionItem_ElementRemovedFromSelectionEventId); } } + for (int slot = 0; slot < 2; ++slot) { + if (old_destination_selected[slot] == state_->destination_selected[slot]) continue; + if (Node *node = retainElementLocked(14 + slot)) + selection_events.emplace_back(node, state_->destination_selected[slot] + ? UIA_SelectionItem_ElementSelectedEventId + : UIA_SelectionItem_ElementRemovedFromSelectionEventId); + } allow_reclaim_changed = old_allow_reclaim != state_->allow_reclaim; confirm_reclaim_changed = old_confirm_reclaim != state_->confirm_each_reclaim; if (allow_reclaim_changed) allow_reclaim_node = retainElementLocked(12); @@ -952,6 +1016,16 @@ class Node final : public IRawElementProviderSimple, for (const auto &event : selection_events) { UiaRaiseAutomationEvent( static_cast(event.first), event.second); + if (event.second != UIA_Selection_InvalidatedEventId) { + VARIANT before{}, after{}; + before.vt = after.vt = VT_BOOL; + const bool now_selected = event.second != UIA_SelectionItem_ElementRemovedFromSelectionEventId; + before.boolVal = now_selected ? VARIANT_FALSE : VARIANT_TRUE; + after.boolVal = now_selected ? VARIANT_TRUE : VARIANT_FALSE; + UiaRaiseAutomationPropertyChangedEvent( + static_cast(event.first), + UIA_SelectionItemIsSelectedPropertyId, before, after); + } event.first->Release(); } raiseToggleChanged(allow_reclaim_node, !allow_reclaim, allow_reclaim); @@ -968,6 +1042,7 @@ class Node final : public IRawElementProviderSimple, UIA_AutomationFocusChangedEventId); focus_node->Release(); } + return S_OK; } void setStatus(const char *status) { std::wstring old_status; @@ -1047,14 +1122,20 @@ class Node final : public IRawElementProviderSimple, bool retired_ = false; bool supportsInvoke() const { - if ((id_ >= 7 && id_ <= 20) || (id_ >= 22 && id_ <= 24)) return true; std::lock_guard lock(state_->mutex); + return isAvailableLocked() && supportsInvokeLocked(); + } + bool supportsInvokeLocked() const { + if ((id_ >= 7 && id_ <= 20) || (id_ >= 22 && id_ <= 24)) return true; const auto row = state_->rows.find(id_); return row != state_->rows.end() && row->second.invokable; } bool supportsToggle() const { return id_ == 12 || id_ == 13; } + bool supportsSelection() const { + return id_ == 0 || id_ == 3 || id_ == 4 || id_ == 21; + } bool supportsScroll() const { return id_ >= 1 && id_ <= 3; } bool supportsScrollItem() const { std::lock_guard lock(state_->mutex); @@ -1113,9 +1194,24 @@ class Node final : public IRawElementProviderSimple, } bool supportsSelectionItem() const { std::lock_guard lock(state_->mutex); - if (!isAvailableLocked() || !isRowKey(id_)) return false; - const Row &row = state_->rows.at(id_); - return !isHeader(row.identity) && row.identity.rfind("workspace-toolbar:", 0) != 0; + return isAvailableLocked() && supportsSelectionItemLocked(); + } + bool supportsSelectionItemLocked() const { + if (id_ == 14 || id_ == 15) return true; + return isRowKey(id_) && isSelectionRow(state_->rows.at(id_)); + } + bool isSelectedLocked() const { + return id_ == 14 || id_ == 15 ? state_->destination_selected[id_ - 14] + : state_->rows.at(id_).selected; + } + int selectionContainerLocked() const { + return id_ == 14 || id_ == 15 || isNavigationRow(state_->rows.at(id_)) + ? 0 : state_->rows.at(id_).parent; + } + static HRESULT postCommand(HWND hwnd, WPARAM command) { + if (PostMessageW(hwnd, WM_COMMAND, command, 0)) return S_OK; + const DWORD error = GetLastError(); + return HRESULT_FROM_WIN32(error == ERROR_SUCCESS ? ERROR_GEN_FAILURE : error); } int64_t rowKeyForIdentityLocked( const std::string &identity, @@ -1349,7 +1445,8 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("loop-disclosure:", 0) == 0; return row.parent == 4 || action ? UIA_ButtonControlTypeId : UIA_ListItemControlTypeId; } - if ((id_ >= 7 && id_ <= 11) || (id_ >= 14 && id_ <= 20) || + if (id_ == 14 || id_ == 15) return UIA_ListItemControlTypeId; + if ((id_ >= 7 && id_ <= 11) || (id_ >= 16 && id_ <= 20) || (id_ >= 22 && id_ <= 24)) return UIA_ButtonControlTypeId; if (id_ == 12 || id_ == 13) return UIA_CheckBoxControlTypeId; if (id_ == 5 || id_ == 21) return UIA_MenuControlTypeId; @@ -1409,6 +1506,16 @@ class Node final : public IRawElementProviderSimple, toggle_node->Release(); } HRESULT setSelected(SelectionOperation operation) { + bool navigation = false; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + if (!supportsSelectionItemLocked()) return UIA_E_INVALIDOPERATION; + navigation = selectionContainerLocked() == 0; + if (navigation && operation != kSelect) + return operation == kAdd && isSelectedLocked() ? S_OK : UIA_E_INVALIDOPERATION; + } + if (navigation) return Invoke(); HWND hwnd = nullptr; bool changed = false; bool dynamic_invoke = false; @@ -1504,13 +1611,23 @@ extern "C" HRESULT gc_uia_update(IRawElementProviderSimple *provider, const char if (!validUtf8(identities[index]) || !validUtf8(names[index])) return E_INVALIDARG; } + int navigation_selected = 0; + for (int index = 0; index < count; ++index) { + if (!selected[index]) continue; + const std::string identity = identities[index]; + Row row; + row.identity = identity; + row.parent = parents[index]; + if (identity == kOverviewDestinationIdentity || identity == kQuickChatsDestinationIdentity || + isNavigationRow(row)) ++navigation_selected; + } + if (navigation_selected > 1) return E_INVALIDARG; auto *root = static_cast(provider); - root->update(status, identities, names, parents, selected, eligible, + return root->update(status, identities, names, parents, selected, eligible, invokable, bounds, count, allow_reclaim != 0, confirm_each_reclaim != 0, worktrees_available != 0, worktree_dialog_open != 0, worktree_row_selected != 0, worktree_busy != 0); - return S_OK; } extern "C" HRESULT gc_uia_set_status(IRawElementProviderSimple *provider, const char *status) { From 536251bd85951dfef580d21a26875c1fa25765de Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 9 Nov 2026 11:17:19 -0700 Subject: [PATCH 02/16] Align sidebar painting and published navigation selection Use one surface-aware, project-qualified loop/chat selection predicate for painting and accessibility. Forward the active chat identity synchronously through canvas painting, retain composite project-surface behavior, and resolve coordinate hits to the canonical Quick Chats destination instead of its duplicate action alias. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../AccessibilityProvider.Native.Tests.cpp | 29 +++- .../src/AccessibilityProvider.cpp | 2 + graphcode-windows/src/App.zig | 126 ++++++++++++++++-- graphcode-windows/src/GraphCanvas.zig | 9 +- graphcode-windows/src/Sidebar.zig | 119 ++++++++++++++--- 5 files changed, 248 insertions(+), 37 deletions(-) diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index f8b9b014..44aaa016 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -22,6 +22,11 @@ static std::vector selectionCommands; static bool rejectSelectionCommand = false; static int forbiddenHostCalls = 0; +static BOOL WINAPI selectionClientToScreen(HWND hwnd, POINT *point) { + if (hwnd) ++forbiddenHostCalls; + *point = {0, 0}; + return TRUE; +} static BOOL WINAPI selectionPostMessage(HWND hwnd, UINT message, WPARAM command, LPARAM argument) { if (hwnd || message != WM_COMMAND || argument) ++forbiddenHostCalls; if (rejectSelectionCommand) { @@ -57,6 +62,7 @@ static LRESULT WINAPI selectionReturnProvider(HWND, WPARAM, LPARAM, IRawElementP // Intercept before including production code, after SDK declarations: no UIA client, // host registration, message delivery, HWND creation, or managed mock type is involved. #define PostMessageW selectionPostMessage +#define ClientToScreen selectionClientToScreen #define UiaRaiseAutomationEvent selectionAutomationEvent #define UiaRaiseAutomationPropertyChangedEvent selectionPropertyEvent #define UiaRaiseStructureChangedEvent selectionStructureEvent @@ -64,6 +70,7 @@ static LRESULT WINAPI selectionReturnProvider(HWND, WPARAM, LPARAM, IRawElementP #define UiaReturnRawElementProvider selectionReturnProvider #include "..\..\..\graphcode-windows\src\AccessibilityProvider.cpp" #undef PostMessageW +#undef ClientToScreen #undef UiaRaiseAutomationEvent #undef UiaRaiseAutomationPropertyChangedEvent #undef UiaRaiseStructureChangedEvent @@ -174,10 +181,15 @@ int main() { const int parents[] = {1, 1, 1, 2, 1, 1, 1, 4, 3}; const int eligible[] = {0, 0, 0, 0, 0, 0, 0, 0, 1}; const int actions[] = {1, 1, 1, 1, 1, 1, 1, 0, 0}; + const int bounds[] = { + 0, 0, 100, 20, 0, 20, 100, 40, 0, 40, 100, 60, + 0, 60, 100, 80, 0, 80, 100, 100, 0, 100, 100, 120, + 0, 20, 100, 40, 100, 0, 200, 200, 0, 120, 100, 140, + }; int selected[] = {1, 0, 0, 0, 0, 0, 0, 0, 1}; auto publish = [&](int count = 9) { check(gc_uia_update(root, "Ready", ids, names, parents, selected, eligible, - actions, nullptr, count, 0, 1, 1, 0, 0, 0) == S_OK, + actions, bounds, count, 0, 1, 1, 0, 0, 0) == S_OK, "headless publication succeeds"); }; publish(); @@ -293,8 +305,19 @@ int main() { "duplicate alias does not expose selection"); if (item) item->Release(); if (pattern) pattern->Release(); - check(selectionBoolProperty(alias, UIA_IsSelectionItemPatternAvailablePropertyId, false), - "alias does not advertise SelectionItem pattern availability"); + bool canonical_hit = true; + if (i == 6) { + auto *fragmentRoot = selectionInterface(root); + IRawElementProviderFragment *hit = nullptr; + canonical_hit = fragmentRoot && + fragmentRoot->ElementProviderFromPoint(10, 30, &hit) == S_OK && + sameSelectionObject(hit, destinations[1]); + if (hit) hit->Release(); + if (fragmentRoot) fragmentRoot->Release(); + } + check(selectionBoolProperty(alias, UIA_IsSelectionItemPatternAvailablePropertyId, false) && + canonical_hit, + "alias is not selectable and hit testing resolves the canonical destination"); alias->Release(); } diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index acede6b1..d716143c 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -577,6 +577,8 @@ class Node final : public IRawElementProviderSimple, } for (int64_t key : state_->row_order) { const Row &row = state_->rows.at(key); + if (row.identity == "quick-chats-header:quick-chats" && + state_->has_destination_bounds[1]) continue; consider(key, hitTier(row.identity), row.bounds); } if (best >= 0) hit = retainElementLocked(best); diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 201b7bb5..3d296799 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -9172,7 +9172,7 @@ pub const App = struct { const node = nodes[row.index]; const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ path, node.id }) catch return; defer self.allocator.free(key); - self.appendAccessibilityElement(&elements, &owned_identities, "loop", key, node.title, 2, .{ .logical = bounds }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, node.id), false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "loop", key, node.title, 2, .{ .logical = bounds }, Sidebar.rowSelected(row, &self.model, self.surface, self.workspaceQuickChatID()), false) catch return; if (row.has_children) { const disclosure_bounds = clipSidebarAccessibilityBounds(Sidebar.disclosureBounds(row), sidebar_bottom); self.appendAccessibilityElement( @@ -9247,7 +9247,7 @@ pub const App = struct { }, .quick_chat => if (row.index < self.model.quick_chats.items.len) { const chat = self.model.quick_chats.items[row.index]; - self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-row", chat.id, chat.title, 1, .{ .logical = bounds }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-row", chat.id, chat.title, 1, .{ .logical = bounds }, Sidebar.rowSelected(row, &self.model, self.surface, self.workspaceQuickChatID()), false) catch return; }, } } @@ -10549,9 +10549,15 @@ fn logWireReceiver(app: *App, event: u32, message: c.UINT, wparam: c.WPARAM, lpa CanvasInput.decodeMouseMessage(0); var buffer: [512]u8 = undefined; const line = app.wire_receiver_trace.project(&buffer, event, message, after, .{ - .x = point.x, .y = point.y, .buttons = wparam, .raw_lparam = lparam, .source = app.edge_drag_source_id, - .edge_dragging = app.canvas.edge_dragging, .node_dragging = app.canvas.node_dragging, - .panning = app.canvas.dragging, .surface = @tagName(app.surface), + .x = point.x, + .y = point.y, + .buttons = wparam, + .raw_lparam = lparam, + .source = app.edge_drag_source_id, + .edge_dragging = app.canvas.edge_dragging, + .node_dragging = app.canvas.node_dragging, + .panning = app.canvas.dragging, + .surface = @tagName(app.surface), }) catch |err| { std.log.err("Wire receiver diagnostic projection failed: {s}", .{@errorName(err)}); return; @@ -10883,7 +10889,7 @@ fn onWindowMessage( if (app.model.currentGraph()) |graph| app.canvas.syncNodeOffsets(graph.nodes.items); _ = app.refreshCanvasLayout(.{ .left = 0, .top = 0, .right = logical_right, .bottom = logical_bottom }); const offered_version = if (app.update_state.state == .available) app.update_version else ""; - GraphCanvas.paint(hdc, logical_right, logical_bottom, &app.model, inspection, app.selected_worktree_path, app.sidebar_scroll, app.status(), offered_version, app.ingress_error, app.connectionFailureVisible(), app.worktree_state, app.declared_entry_ids.items, app.kept_worktree_paths.items, app.allocator, &app.canvas, &app.sidebar_state, app.sidebar_hover_y, app.workspace_controls, app.surface, app.selection_active); + GraphCanvas.paint(hdc, logical_right, logical_bottom, &app.model, inspection, app.selected_worktree_path, app.sidebar_scroll, app.status(), offered_version, app.ingress_error, app.connectionFailureVisible(), app.worktree_state, app.declared_entry_ids.items, app.kept_worktree_paths.items, app.allocator, &app.canvas, &app.sidebar_state, app.sidebar_hover_y, app.workspace_controls, app.surface, app.selection_active, app.workspaceQuickChatID()); app.update_lock.unlock(); if (app.workspace_controls.panel_visible or app.surface == .workspace) { if (app.surface == .workspace) { @@ -13742,6 +13748,89 @@ fn expectSidebarNavigationElement( return sink.rects[index]; } +test "headless canonical sidebar selection follows the visible destination and project owner" { + const allocator = std.testing.allocator; + var app: App = .{ + .allocator = allocator, + .client = .{ .allocator = allocator, .frame_buffer = try @import("FrameBuffer.zig").FrameBuffer.init(allocator, .v2) }, + .daemon = undefined, + .model = GraphModel.Model.init(allocator), + .sidebar_state = Sidebar.State.init(allocator), + .declared_entry_ids = std.array_list.Managed([]u8).init(allocator), + .kept_worktree_paths = std.array_list.Managed([]u8).init(allocator), + .workspace_controls = .{ .rail_visible = true, .panel_visible = false, .activity_enabled = false }, + }; + defer app.client.deinit(); + defer app.canvas.deinitLayout(); + defer app.model.deinit(); + defer app.sidebar_state.deinit(); + defer app.declared_entry_ids.deinit(); + defer app.kept_worktree_paths.deinit(); + const frames = [_][]const u8{ + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"A","name":"Alpha"},"nodes":[{"id":"same","title":"Alpha loop","state":"idle"},{"id":"group","title":"Group","loopType":"composite","subGraph":{"nodes":[{"id":"inner","title":"Inner"}],"edges":[]}}],"edges":[]}}} + , + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"project":{"path":"B","name":"Beta"},"nodes":[{"id":"same","title":"Beta loop","state":"idle"}],"edges":[]}}} + , + \\{"version":2,"kind":"event","sequence":3,"event":{"quickChatsListed":[{"id":"chat","title":"Chat","backend":"claudeCode"}]}} + , + }; + for (frames) |frame| _ = try app.model.updateFromFrame(frame); + try std.testing.expect(app.model.selectProject("A")); + var chat_id = [_]u8{ 'c', 'h', 'a', 't' }; + var workspace: TerminalWorkspace.Workspace = .{ + .parent = null, + .allocator = allocator, + .zmx_path = &.{}, + .cwd = &.{}, + .input_queue = .{ .allocator = allocator }, + .layout = try @import("WorkspaceLayout.zig").Layout.init(allocator, TerminalWorkspace.quick_chat_scope_project), + .layout_path = &.{}, + .project_key = &.{}, + .project_path = &.{}, + .loop_id = &chat_id, + }; + defer workspace.layout.deinit(); + defer allocator.free(workspace.agent_tab_title); + defer allocator.free(workspace.agent_pane_detail); + const cases = [_]struct { + surface: GraphCanvas.Surface, + chat_workspace: bool = false, + selected: []const u8, + }{ + .{ .surface = .overview, .selected = "overview-destination:graph" }, + .{ .surface = .quick_chats, .selected = "quick-chats-destination:quick-chats" }, + .{ .surface = .project, .selected = "open-project:A" }, + .{ .surface = .workspace, .selected = "loop:A:same" }, + .{ .surface = .workspace, .chat_workspace = true, .selected = "quick-chat-row:chat" }, + }; + const destinations = [_][]const u8{ + "overview-destination:graph", "quick-chats-destination:quick-chats", + "open-project:A", "open-project:B", + "loop:A:same", "loop:B:same", + "loop:A:group", "quick-chat-row:chat", + }; + for (cases) |case| { + app.surface = case.surface; + app.workspace = if (case.chat_workspace) &workspace else null; + var sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + var selected_count: usize = 0; + for (destinations) |identity| { + const expected = std.mem.eql(u8, identity, case.selected); + try expectSidebarNavigationSelection(&sink, identity, expected); + if (sink.selected[sink.indexOfName(identity).?]) selected_count += 1; + } + try std.testing.expectEqual(@as(usize, 1), selected_count); + } + app.workspace = null; + app.surface = .project; + try std.testing.expect(app.model.openComposite("group")); + var composite_sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&composite_sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + for (destinations) |identity| + try expectSidebarNavigationSelection(&composite_sink, identity, std.mem.eql(u8, identity, "open-project:A")); +} + test "parity sidebar navigation UIA matches painted owners and clipped row controls" { for ([_]u32{ 96, 144, 192 }) |dpi| { var app = try overviewTestApp(dpi); @@ -13783,6 +13872,11 @@ test "parity sidebar navigation UIA matches painted owners and clipped row contr const quick_chats_row = for (rows.items) |row| { if (row.kind == .quick_chat_overview) break row; } else return error.TestUnexpectedResult; + const local_loop_row = navigationRowForProject(rows.items, .loop, "C:\\local") orelse + return error.TestUnexpectedResult; + const chat_row = for (rows.items) |row| { + if (row.kind == .quick_chat) break row; + } else return error.TestUnexpectedResult; try std.testing.expect(app.model.selectProject("C:\\local")); var temporary = std.testing.tmpDir(.{}); defer temporary.cleanup(); @@ -13826,18 +13920,23 @@ test "parity sidebar navigation UIA matches painted owners and clipped row contr try expectSidebarNavigationSelection(&route_sink, "open-project:C:\\local", case.local); try expectSidebarNavigationSelection(&route_sink, "open-project:ssh://host/remote", false); try expectSidebarNavigationSelection(&route_sink, "open-project:codespace://fixture/loading", false); + try expectSidebarNavigationSelection(&route_sink, "loop:C:\\local:local-loop", case.surface == .workspace and !case.quick_chat_workspace); + try expectSidebarNavigationSelection(&route_sink, "loop:graphcode://global:global-loop", false); + try expectSidebarNavigationSelection(&route_sink, "quick-chat-row:chat", case.quick_chat_workspace); { var bitmap = try paintSidebarBitmap(&app, client.bottom, false, 0); defer bitmap.deinit(); - const row_selections = [_]struct { row: Sidebar.Row, selected: bool }{ + const row_selections = [_]struct { row: Sidebar.Row, selected: bool, x: i32 = 190 }{ .{ .row = graph_row, .selected = case.graph }, .{ .row = quick_chats_row, .selected = case.quick_chats }, .{ .row = local_row, .selected = case.local }, + .{ .row = local_loop_row, .selected = case.surface == .workspace and !case.quick_chat_workspace, .x = 16 }, + .{ .row = chat_row, .selected = case.quick_chat_workspace, .x = 16 }, }; for (row_selections) |selection| { const expected_fill = dibColor(if (selection.selected) Tokens.pane_focus_tint else Tokens.workspace_rail); - try std.testing.expectEqual(expected_fill, bitmap.pixel(190, selection.row.top + 10)); + try std.testing.expectEqual(expected_fill, bitmap.pixel(selection.x, selection.row.top + 10)); } app.selection_active = false; @@ -13845,7 +13944,7 @@ test "parity sidebar navigation UIA matches painted owners and clipped row contr defer inactive_bitmap.deinit(); for (row_selections) |selection| { const expected_fill = dibColor(if (selection.selected) 0x003A3A44 else Tokens.workspace_rail); - try std.testing.expectEqual(expected_fill, inactive_bitmap.pixel(190, selection.row.top + 10)); + try std.testing.expectEqual(expected_fill, inactive_bitmap.pixel(selection.x, selection.row.top + 10)); } } } @@ -14240,6 +14339,7 @@ fn paintedIngressFooter(app: *App, sidebar_bottom: i32, update: bool) !?c.RECT { hdc, &app.model, app.surface, + app.workspaceQuickChatID(), app.selection_active, app.currentWorktreeInspection(), "", @@ -14606,6 +14706,7 @@ fn paintSidebarBitmap(app: *App, viewport_bottom: i32, update: bool, scroll: i32 dc, &app.model, app.surface, + app.workspaceQuickChatID(), app.selection_active, app.currentWorktreeInspection(), "", @@ -23781,8 +23882,7 @@ test "wire capture real window procedure preserves reentrant same-window SetCapt try MainWindow.registerClass(instance); app.window.hwnd = null; app.window.context = &app; - const hwnd = c.CreateWindowExW(0, app.window.class_name, app.window.class_name, - c.WS_POPUP, 0, 0, 1200, 700, null, null, instance, &app.window) orelse { + const hwnd = c.CreateWindowExW(0, app.window.class_name, app.window.class_name, c.WS_POPUP, 0, 0, 1200, 700, null, null, instance, &app.window) orelse { app.window.hwnd = original; return error.WindowCreationFailed; }; @@ -23816,9 +23916,7 @@ test "wire capture real window procedure preserves reentrant same-window SetCapt _ = c.SendMessageW(hwnd, c.WM_ACTIVATEAPP, 0, 0); try std.testing.expect(!app.canvas.edge_dragging); try std.testing.expectEqual(@as(usize, 0), app.edge_drag_source_id.len); - const foreign = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC"), - std.unicode.utf8ToUtf16LeStringLiteral("Capture peer"), c.WS_POPUP, - 0, 0, 100, 100, null, null, instance, null) orelse return error.WindowCreationFailed; + const foreign = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC"), std.unicode.utf8ToUtf16LeStringLiteral("Capture peer"), c.WS_POPUP, 0, 0, 100, 100, null, null, instance, null) orelse return error.WindowCreationFailed; defer _ = c.DestroyWindow(foreign); app.beginWireSelectedNode(0); _ = c.SetCapture(foreign); diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index ffe1e45d..124a520a 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -969,6 +969,7 @@ pub fn paint( controls: WorkspaceControls.State, surface: Surface, selection_active: bool, + active_quick_chat_id: ?[]const u8, ) void { const client = c.RECT{ .left = 0, .top = 0, .right = client_right, .bottom = client_bottom }; fill(hdc, client, Tokens.canvas_tone); @@ -985,6 +986,7 @@ pub fn paint( hdc, model, surface, + active_quick_chat_id, selection_active, visible_inspection, selected_worktree_path, @@ -1120,6 +1122,7 @@ pub fn paint( hdc, model, surface, + active_quick_chat_id, selection_active, visible_inspection, selected_worktree_path, @@ -1981,6 +1984,7 @@ test "responsive overlay rail background stops above the full-width Activity str .{ .rail_visible = true, .panel_visible = false, .activity_enabled = true }, .overview, true, + null, ); const strip_top = client.bottom - Tokens.activity_strip_height; @@ -6048,6 +6052,7 @@ test "responsive overlay paints the Quick Chat empty action in the visible detai controls, .quick_chats, true, + null, ); const full_bounds = rect(bounds.left, bounds.top, bounds.right, bounds.bottom); @@ -7189,6 +7194,7 @@ fn paintExportFixture( controls, surface, true, + null, ); paintHeader( dib.dc, @@ -8205,7 +8211,7 @@ fn paintVisualFinish(dib: ParityDib, allocator: std.mem.Allocator, scene: Visual defer state.deinitLayout(); var sidebar = Sidebar.State.init(allocator); defer sidebar.deinit(); - paint(dib.dc, 1280, 720, &model, null, "", 0, "", "", "", false, "", &.{}, &.{}, allocator, &state, &sidebar, -1, .{ .rail_visible = true, .panel_visible = false, .activity_enabled = false }, surface, true); + paint(dib.dc, 1280, 720, &model, null, "", 0, "", "", "", false, "", &.{}, &.{}, allocator, &state, &sidebar, -1, .{ .rail_visible = true, .panel_visible = false, .activity_enabled = false }, surface, true, null); paintHeader(dib.dc, allocator, 1280, "", .{ .title = "Canvas Fixture", .project_glyph = if (surface == .workspace) .folder else null, @@ -8522,6 +8528,7 @@ fn chrome764Paint( .{ .graph_visible = true, .rail_visible = true, .panel_visible = false, .activity_enabled = false }, if (scene == .project) .project else .overview, true, + null, ); if (c.GdiFlush() == 0) return error.Chrome764FlushFailed; } diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index b1af2bec..d702e608 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -173,6 +173,7 @@ pub fn draw( hdc: c.HDC, model: *const GraphModel.Model, surface: Surface, + active_quick_chat_id: ?[]const u8, selection_active: bool, inspection: ?*const WorktreeStatus.Inspection, selected_worktree_path: []const u8, @@ -254,6 +255,8 @@ pub fn draw( .loop => if (row.project_path) |path| if (graphForProject(model, path)) |graph| { const nodes = graph.nodes(); if (row.index < nodes.len) { + if (rowSelected(row, model, surface, active_quick_chat_id)) + drawSelection(hdc, bounds, selection_active); const node = nodes[row.index]; const indent = loopIndent(row.depth); const elapsed = elapsedText(allocator, @intCast(node.created_at orelse 0), std.time.timestamp()) catch |err| blk: { @@ -301,8 +304,11 @@ pub fn draw( if (row.has_children) drawDisclosureIcon(hdc, chatsDisclosureBounds(row), state.chats_collapsed, 0x00B8B8B8); } }, - .quick_chat => if (row.index < model.quick_chats.items.len) - drawTextRect(hdc, allocator, model.quick_chats.items[row.index].title, rect(34, row.top + 5, Tokens.sidebar_width - 12, row.top + 27), 13, 0x00E6E6E6, loop_text_format), + .quick_chat => if (row.index < model.quick_chats.items.len) { + if (rowSelected(row, model, surface, active_quick_chat_id)) + drawSelection(hdc, bounds, selection_active); + drawTextRect(hdc, allocator, model.quick_chats.items[row.index].title, rect(34, row.top + 5, Tokens.sidebar_width - 12, row.top + 27), 13, 0x00E6E6E6, loop_text_format); + }, } } @@ -636,6 +642,27 @@ pub fn navigationRowSelected(row: Row, surface: Surface, selected_project_path: }; } +pub fn rowSelected(row: Row, model: *const GraphModel.Model, surface: Surface, active_quick_chat_id: ?[]const u8) bool { + switch (row.kind) { + .loop => { + if (surface != .workspace or active_quick_chat_id != null) return false; + const path = row.project_path orelse return false; + const selected_path = model.selected_project_path orelse return false; + if (!std.mem.eql(u8, path, selected_path)) return false; + const selected_node = model.selected_node_id orelse return false; + const graph = graphForProject(model, path) orelse return false; + const nodes = graph.nodes(); + return row.index < nodes.len and std.mem.eql(u8, nodes[row.index].id, selected_node); + }, + .quick_chat => { + if (surface != .workspace or row.index >= model.quick_chats.items.len) return false; + const id = active_quick_chat_id orelse return false; + return std.mem.eql(u8, model.quick_chats.items[row.index].id, id); + }, + else => return navigationRowSelected(row, surface, model.selected_project_path), + } +} + pub fn graphForProject(model: *const GraphModel.Model, path: []const u8) ?ProjectGraph { if (model.graphFor(path)) |graph| return .{ .summary = graph }; if (model.graph) |*graph| { @@ -2182,7 +2209,7 @@ test "sidebar framing starts with navigation instead of a fixed title block" { defer state.deinit(); var bitmap = try TestSidebarBitmap.init(); defer bitmap.deinit(); - draw(bitmap.hdc, &model, .workspace, false, null, "", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, .workspace, null, false, null, "", 0, "", 800, "", "", &state, -1, allocator); var painted_rows: usize = 0; for (rows.items) |row| { var pixels: usize = 0; @@ -2274,12 +2301,12 @@ test "remaining sidebar interaction parity paints active navigation selection bl break row; } else return error.TestUnexpectedResult; - draw(bitmap.hdc, &model, case.surface, true, null, "", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, case.surface, null, true, null, "", 0, "", 800, "", "", &state, -1, allocator); try std.testing.expectEqual( @as(u32, Tokens.pane_focus_tint), c.GetPixel(bitmap.hdc, 100, selected_row.top + 1), ); - draw(bitmap.hdc, &model, case.surface, false, null, "", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, case.surface, null, false, null, "", 0, "", 800, "", "", &state, -1, allocator); try std.testing.expectEqual( @as(u32, 0x003A3A44), c.GetPixel(bitmap.hdc, 100, selected_row.top + 1), @@ -2315,7 +2342,7 @@ test "remaining sidebar interaction parity keeps navigation controls hover-only const control_color: u32 = 0x00B8B8B8; const heading_color: u32 = 0x00909090; - draw(bitmap.hdc, &model, .workspace, false, null, "", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, .workspace, null, false, null, "", 0, "", 800, "", "", &state, -1, allocator); var navigation_rows: usize = 0; var headings: usize = 0; for (rows.items) |row| { @@ -2336,7 +2363,7 @@ test "remaining sidebar interaction parity keeps navigation controls hover-only try std.testing.expectEqual(@as(usize, 0), sidebarTestPixelCount(bitmap, add_bounds, control_color)); try std.testing.expectEqual(@as(usize, 0), sidebarTestPixelCount(bitmap, disclosure, control_color)); - draw(bitmap.hdc, &model, .workspace, false, null, "", 0, "", 800, "", "", &state, row.top + 8, allocator); + draw(bitmap.hdc, &model, .workspace, null, false, null, "", 0, "", 800, "", "", &state, row.top + 8, allocator); try std.testing.expect(sidebarTestPixelCount(bitmap, add_bounds, control_color) > 0); try expectSidebarTestDisclosureDirection(bitmap, disclosure, control_color, false); navigation_rows += 1; @@ -2344,7 +2371,7 @@ test "remaining sidebar interaction parity keeps navigation controls hover-only .local_heading, .remote_heading => { const disclosure = rect(198, row.top, rowBounds(row).right, row.top + 20); try std.testing.expectEqual(@as(usize, 0), sidebarTestPixelCount(bitmap, disclosure, heading_color)); - draw(bitmap.hdc, &model, .workspace, false, null, "", 0, "", 800, "", "", &state, row.top + 8, allocator); + draw(bitmap.hdc, &model, .workspace, null, false, null, "", 0, "", 800, "", "", &state, row.top + 8, allocator); try expectSidebarTestDisclosureDirection(bitmap, disclosure, heading_color, false); headings += 1; }, @@ -2363,11 +2390,65 @@ test "remaining sidebar interaction parity keeps navigation controls hover-only for (collapsed_rows.items) |row| { if (row.kind != .overview and row.kind != .open_project and row.kind != .quick_chat_overview) continue; const disclosure = if (row.kind == .quick_chat_overview) chatsDisclosureBounds(row) else disclosureBounds(row); - draw(bitmap.hdc, &model, .workspace, false, null, "", 0, "", 800, "", "", &state, row.top + 8, allocator); + draw(bitmap.hdc, &model, .workspace, null, false, null, "", 0, "", 800, "", "", &state, row.top + 8, allocator); try expectSidebarTestDisclosureDirection(bitmap, disclosure, control_color, true); } } +test "headless canonical sidebar selection keeps workspace identities separate from backing model selection" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"A","name":"Alpha"},"nodes":[{"id":"same","title":"Alpha loop"},{"id":"group","title":"Group","loopType":"composite","subGraph":{"nodes":[{"id":"inner","title":"Inner"}],"edges":[]}}],"edges":[]}}} + ); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"project":{"path":"B","name":"Beta"},"nodes":[{"id":"same","title":"Beta loop"}],"edges":[]}}} + ); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":3,"event":{"quickChatsListed":[{"id":"chat","title":"Chat","backend":"claudeCode"}]}} + ); + try std.testing.expect(model.selectProject("A")); + const rows = [_]Row{ + .{ .kind = .overview, .index = 0, .top = 0 }, + .{ .kind = .quick_chat_overview, .index = 0, .top = 0 }, + .{ .kind = .open_project, .index = 0, .top = 0, .project_path = "A" }, + .{ .kind = .loop, .index = 0, .top = 0, .project_path = "A" }, + .{ .kind = .loop, .index = 0, .top = 0, .project_path = "B" }, + .{ .kind = .quick_chat, .index = 0, .top = 0 }, + .{ .kind = .loop, .index = 1, .top = 0, .project_path = "A" }, + }; + const cases = [_]struct { surface: Surface, chat: ?[]const u8 = null, selected: usize }{ + .{ .surface = .overview, .selected = 0 }, + .{ .surface = .quick_chats, .selected = 1 }, + .{ .surface = .project, .selected = 2 }, + .{ .surface = .workspace, .selected = 3 }, + .{ .surface = .workspace, .chat = "chat", .selected = 5 }, + .{ .surface = .project, .chat = "chat", .selected = 2 }, + }; + for (cases) |case| { + var selected_count: usize = 0; + for (rows, 0..) |row, index| { + const selected = rowSelected(row, &model, case.surface, case.chat); + try std.testing.expectEqual(index == case.selected, selected); + if (selected) selected_count += 1; + } + try std.testing.expectEqual(@as(usize, 1), selected_count); + } + try std.testing.expect(!rowSelected(rows[3], &model, .workspace, "stale-chat")); + try std.testing.expect(!rowSelected(rows[5], &model, .workspace, "stale-chat")); + try std.testing.expect(!rowSelected(.{ .kind = .loop, .index = 99, .top = 0, .project_path = "A" }, &model, .workspace, null)); + try std.testing.expect(!rowSelected(.{ .kind = .loop, .index = 0, .top = 0, .project_path = "missing" }, &model, .workspace, null)); + try std.testing.expect(!rowSelected(.{ .kind = .quick_chat, .index = 99, .top = 0 }, &model, .workspace, "chat")); + try std.testing.expect(model.setSelectedIndex(1)); + try std.testing.expect(rowSelected(rows[6], &model, .workspace, null)); + try std.testing.expect(!rowSelected(rows[3], &model, .workspace, null)); + try std.testing.expect(model.openComposite("group")); + try std.testing.expect(rowSelected(rows[2], &model, .project, null)); + try std.testing.expect(!rowSelected(rows[3], &model, .project, null)); + try std.testing.expect(!rowSelected(rows[6], &model, .project, null)); +} + test "sidebar framing rounds every selected navigation surface inside the rail" { // macOS delegates selection to List.listStyle(.sidebar), not a full-width fill. const allocator = std.testing.allocator; @@ -2384,7 +2465,7 @@ test "sidebar framing rounds every selected navigation surface inside the rail" var rows = try appendRows(allocator, &model, null, 0, &state); defer rows.deinit(allocator); for (surfaces, kinds) |surface, kind| { - draw(bitmap.hdc, &model, surface, false, null, "", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, surface, null, false, null, "", 0, "", 800, "", "", &state, -1, allocator); const row = for (rows.items) |candidate| { if (candidate.kind == kind) break candidate; } else return error.TestUnexpectedResult; @@ -2423,12 +2504,12 @@ test "sidebar framing rounds reclaimable worktree selection without selecting un defer state.deinit(); var bitmap = try TestSidebarBitmap.init(); defer bitmap.deinit(); - draw(bitmap.hdc, &model, .workspace, false, &inspection, "worktree", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, .workspace, null, false, &inspection, "worktree", 0, "", 800, "", "", &state, -1, allocator); const bounds = rowBounds(row); try std.testing.expectEqual(@as(u32, Tokens.workspace_rail), c.GetPixel(bitmap.hdc, bounds.left, bounds.top)); try std.testing.expectEqual(@as(u32, 0x003A3A44), c.GetPixel(bitmap.hdc, 100, bounds.top + 1)); inspection.entries.items[0].dirty = true; - draw(bitmap.hdc, &model, .workspace, false, &inspection, "worktree", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, .workspace, null, false, &inspection, "worktree", 0, "", 800, "", "", &state, -1, allocator); try std.testing.expectEqual(@as(u32, Tokens.workspace_rail), c.GetPixel(bitmap.hdc, 100, bounds.top + 1)); } @@ -2511,7 +2592,7 @@ test "sidebar row parity paints solid and hollow states on the production draw p defer state.deinit(); var bitmap = try TestSidebarBitmap.init(); defer bitmap.deinit(); - draw(bitmap.hdc, &model, .overview, false, null, "", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, .overview, null, false, null, "", 0, "", 800, "", "", &state, -1, allocator); var rows = try appendRows(allocator, &model, null, 0, &state); defer rows.deinit(allocator); const colors = [_]u32{ 0x000A9FFF, 0x000A9FFF, 0x00FF840A, 0x00FF840A, 0x0058D130, 0x003A45FF, 0x00F25ABF, 0x006F6C6C, 0x006F6C6C }; @@ -2572,7 +2653,7 @@ test "sidebar row parity keeps section controls trailing and header icons aligne defer bitmap.deinit(); var rows = try appendRows(allocator, &model, null, 0, &state); defer rows.deinit(allocator); - draw(bitmap.hdc, &model, .workspace, false, null, "", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, .workspace, null, false, null, "", 0, "", 800, "", "", &state, -1, allocator); var headers_checked: usize = 0; var sections_checked: usize = 0; for (rows.items) |row| { @@ -2596,7 +2677,7 @@ test "sidebar row parity keeps section controls trailing and header icons aligne while (x < 220) : (x += 1) try std.testing.expectEqual(@as(u32, Tokens.workspace_rail), c.GetPixel(bitmap.hdc, x, y)); } - draw(bitmap.hdc, &model, .workspace, false, null, "", 0, "", 800, "", "", &state, row.top + 8, allocator); + draw(bitmap.hdc, &model, .workspace, null, false, null, "", 0, "", 800, "", "", &state, row.top + 8, allocator); var chevron_pixels: usize = 0; y = row.top; while (y < row.top + 20) : (y += 1) { @@ -2628,7 +2709,7 @@ test "sidebar row parity gives short elapsed labels spare title space" { defer state.deinit(); var bitmap = try TestSidebarBitmap.init(); defer bitmap.deinit(); - draw(bitmap.hdc, &model, .overview, false, null, "", 0, "", 800, "", "", &state, -1, allocator); + draw(bitmap.hdc, &model, .overview, null, false, null, "", 0, "", 800, "", "", &state, -1, allocator); var rows = try appendRows(allocator, &model, null, 0, &state); defer rows.deinit(allocator); const loop = for (rows.items) |row| { @@ -2890,7 +2971,7 @@ test "sidebar draw clips rows to the content viewport and keeps status text insi var draw_allocator = std.heap.DebugAllocator(.{}){}; defer std.debug.assert(draw_allocator.deinit() == .ok); const long_status = "S" ** 120; - draw(hdc, &model, .project, false, null, "", 0, long_status, viewport_bottom, "", "", &state, -1, draw_allocator.allocator()); + draw(hdc, &model, .project, null, false, null, "", 0, long_status, viewport_bottom, "", "", &state, -1, draw_allocator.allocator()); const content_bottom = contentViewportBottom(viewport_bottom, false, false); const status = statusTextRect(viewport_bottom, false, false); @@ -2937,7 +3018,7 @@ test "sidebar paints an open project whose daemon name is empty" { defer _ = c.SelectObject(hdc, previous); var draw_allocator = std.heap.DebugAllocator(.{}){}; defer std.debug.assert(draw_allocator.deinit() == .ok); - draw(hdc, &model, .project, false, null, "", 0, "", 800, "", "", &state, -1, draw_allocator.allocator()); + draw(hdc, &model, .project, null, false, null, "", 0, "", 800, "", "", &state, -1, draw_allocator.allocator()); } fn manyLoopModel(allocator: std.mem.Allocator, loops: usize) !GraphModel.Model { @@ -3001,7 +3082,7 @@ test "scrolled rows are not drawn over the application header inset" { defer _ = c.SelectObject(hdc, previous); var draw_allocator = std.heap.DebugAllocator(.{}){}; defer std.debug.assert(draw_allocator.deinit() == .ok); - draw(hdc, &model, .project, false, null, "", scroll, "", viewport_bottom, "", "", &state, -1, draw_allocator.allocator()); + draw(hdc, &model, .project, null, false, null, "", scroll, "", viewport_bottom, "", "", &state, -1, draw_allocator.allocator()); // The goalBased accent bar is only ever painted by loop rows. const accent: u32 = 0x00709E19; From 21a10647a84e464d905e8fdec93fab8674da64fa Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 9 Nov 2026 11:10:17 -0700 Subject: [PATCH 03/16] Wire explicit headless navigation selection provider contracts Keep the default native 280-check entry separate. Require the intercepted C++ branch, exact 179-check summary, retained scratch, and source-only compile-plan controls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit 8476d42c592d4ceb4978a555700b4704bd4a924c) --- ...ityProvider.Native.Tests.Harness.Tests.ps1 | 58 ++++++++++++++++- .../AccessibilityProvider.Native.Tests.ps1 | 63 ++++++++++++++----- .../ValidationRunner/Manifests/ui.json | 1 + .../ValidationRunner/Ui/SuiteRegistration.cs | 6 +- Tools/windows/validate.ps1 | 5 ++ Tools/windows/validation-matrix.md | 2 + 6 files changed, 116 insertions(+), 19 deletions(-) diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 index 4bc196b8..ae9cd11a 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 @@ -1,7 +1,63 @@ [CmdletBinding()] -param() +param( + [switch] $PlanOnly, + [string] $RepositoryRoot, + [string] $ArtifactRoot, + [string] $AssemblyPath +) $ErrorActionPreference = "Stop" +$nativeScript = Join-Path $PSScriptRoot "AccessibilityProvider.Native.Tests.ps1" +$parseErrors = $null +$nativeAst = [Management.Automation.Language.Parser]::ParseFile($nativeScript, [ref]$null, [ref]$parseErrors) +if ($parseErrors.Count -ne 0) { throw "Native accessibility runner must parse" } +$planFunctions = @($nativeAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Get-AccessibilityProviderNativeTestPlan" +}, $true)) +if ($planFunctions.Count -ne 1) { throw "Native accessibility needs one shared compile plan" } +. ([scriptblock]::Create($planFunctions[0].Extent.Text)) +$planChecks = 0 +foreach ($headless in @($false, $true)) { + $plan = Get-AccessibilityProviderNativeTestPlan $headless + $expected = if ($headless) { + @("/DGC_UIA_HEADLESS_SELECTION", "", "oleaut32.lib user32.lib uuid.lib", + "Headless navigation selection: 179 checks, 0 failures") + } else { + @("", '"%~3"', "oleaut32.lib uiautomationcore.lib user32.lib", + "Native accessibility provider: 280 checks, 0 failures") + } + $actual = @($plan.definitions, $plan.providerSource, $plan.libraries, $plan.summary) + for ($index = 0; $index -lt $expected.Count; $index++) { + if ($actual[$index] -cne $expected[$index]) { throw "Wrong native compile plan: headless=$headless field=$index" } + $planChecks++ + } +} +$nativeSource = [IO.File]::ReadAllText($nativeScript) +foreach ($literal in @( + '.Replace("TEST_DEFINITIONS", $plan.definitions)', + 'Replace("PROVIDER_SOURCE", $plan.providerSource)', + 'Replace("TEST_LIBRARIES", $plan.libraries)', + '$_ -ceq $plan.summary', + 'if ($HeadlessSelection)', + 'GC_UIA_HEADLESS_SELECTION' +)) { + if (-not $nativeSource.Contains($literal)) { throw "Native compile plan is not enforced: $literal" } + $planChecks++ +} +Write-Host "Native accessibility compile plan: $planChecks checks, 0 failures (source/data only)" +if ($PlanOnly) { + if ($ArtifactRoot) { + Write-Output ("UI_CASE_RESULT=" + ([ordered]@{ + id = "ui.navigation.headless-runner-plan"; status = "Passed"; executedCount = $planChecks + evidence = "Actual native runner compile plan; source/data controls; compilerCalls=0; nativeCalls=0" + } | ConvertTo-Json -Compress)) + return + } + return $planChecks +} + $scratch = Join-Path ([IO.Path]::GetTempPath()) ("graphcode uia harness-" + [char]0xe9 + [guid]::NewGuid()) function Test-AbruptStage([string] $Stage, [switch] $RepeatedPath) { $fixture = Join-Path $scratch $(if ($RepeatedPath) { "$Stage-repeated-path" } else { $Stage }) diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 index 20d8e0d7..1ea5a3af 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 @@ -1,8 +1,33 @@ [CmdletBinding()] -param([string] $VcVars) +param([string] $VcVars, [switch] $HeadlessSelection) $ErrorActionPreference = "Stop" +function Get-AccessibilityProviderNativeTestPlan([bool] $HeadlessSelection) { + if ($HeadlessSelection) { + return [pscustomobject]@{ + definitions = "/DGC_UIA_HEADLESS_SELECTION" + providerSource = "" + libraries = "oleaut32.lib user32.lib uuid.lib" + summary = "Headless navigation selection: 179 checks, 0 failures" + } + } + return [pscustomobject]@{ + definitions = "" + providerSource = '"%~3"' + libraries = "oleaut32.lib uiautomationcore.lib user32.lib" + summary = "Native accessibility provider: 280 checks, 0 failures" + } +} +$plan = Get-AccessibilityProviderNativeTestPlan ([bool]$HeadlessSelection) $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$test = Join-Path $PSScriptRoot "AccessibilityProvider.Native.Tests.cpp" +if ($HeadlessSelection) { + $testSource = [IO.File]::ReadAllText($test) + if ($testSource -notmatch '(?m)^#if(?:def\s+GC_UIA_HEADLESS_SELECTION|[ \t]+defined\s*\(\s*GC_UIA_HEADLESS_SELECTION\s*\))' -or + -not $testSource.Contains("Headless navigation selection:")) { + throw "Headless selection requires the intercepted C++ test branch; the legacy native entry will not run" + } +} if (-not $VcVars) { $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" if (-not (Test-Path -LiteralPath $vswhere)) { throw "Visual Studio vswhere is required" } @@ -34,7 +59,6 @@ try { if ($env:PATH.Length -ge 8191) { throw "Native accessibility provider deduplicated PATH is still too long for cmd: $($env:PATH.Length)" } - $test = Join-Path $PSScriptRoot "AccessibilityProvider.Native.Tests.cpp" $provider = Join-Path $repoRoot "graphcode-windows\src\AccessibilityProvider.cpp" # Change directory inside cmd only: a Push-Location here becomes this process's # working directory at the native call and then locks the scratch folder. @@ -58,7 +82,7 @@ set "where_exit=%errorlevel%" echo [native-uia] stage=where-cl exit=%where_exit% if not "%where_exit%"=="0" exit /b %where_exit% echo [native-uia] stage=compile start -cl /nologo /EHsc /std:c++17 /Fe:uia-native.exe "%~2" "%~3" oleaut32.lib uiautomationcore.lib user32.lib +cl /nologo /EHsc /std:c++17 TEST_DEFINITIONS /Fe:uia-native.exe "%~2" PROVIDER_SOURCE TEST_LIBRARIES set "compile_exit=%errorlevel%" echo [native-uia] stage=compile exit=%compile_exit% if not "%compile_exit%"=="0" exit /b %compile_exit% @@ -68,6 +92,9 @@ set "test_exit=%errorlevel%" echo [native-uia] stage=test-exe exit=%test_exit% exit /b %test_exit% '@ + $command = $command.Replace("TEST_DEFINITIONS", $plan.definitions). + Replace("PROVIDER_SOURCE", $plan.providerSource). + Replace("TEST_LIBRARIES", $plan.libraries) $commandPath = Join-Path $scratch "run-native.cmd" [IO.File]::WriteAllText($commandPath, $command.Replace("`r`n", "`n").Replace("`n", "`r`n"), [Text.Encoding]::ASCII) @@ -90,25 +117,27 @@ exit /b %test_exit% $setupPathLength = $setupPath[0].Substring(5).Length Write-Host "[native-uia] post-vcvars PATH length=$setupPathLength (cmd limit=8191)" if ($setupPathLength -ge 8191) { throw "Native accessibility provider post-vcvars PATH exceeds cmd limit: $setupPathLength" } - if (@($nativeOutput | Where-Object { $_ -eq "Native accessibility provider: 280 checks, 0 failures" }).Count -ne 1) { - throw "Native accessibility provider test-exe did not report exactly one 280-check, zero-failure summary" + if (@($nativeOutput | Where-Object { $_ -ceq $plan.summary }).Count -ne 1) { + throw "Native accessibility provider test-exe did not report exactly one expected summary: $($plan.summary)" } } finally { $env:TEMP = $savedTemp $env:TMP = $savedTmp $env:PATH = $savedPath - # The just-built test executable and compiler outputs can stay locked briefly - # (for example by an antivirus scan) after the process exits. Cleanup must not - # turn a passing run into a failure, so retry and then only warn. - for ($attempt = 1; Test-Path -LiteralPath $scratch; $attempt++) { - try { - Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction Stop - } catch { - if ($attempt -ge 10) { - Write-Warning "Native accessibility test scratch was not removed: $scratch ($($_.Exception.Message))" - break + if ($HeadlessSelection) { + Write-Host "HEADLESS_SELECTION_RETAINED_SCRATCH=$scratch" + } else { + # Native compiler outputs can stay locked briefly after the process exits. + for ($attempt = 1; Test-Path -LiteralPath $scratch; $attempt++) { + try { + Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction Stop + } catch { + if ($attempt -ge 10) { + Write-Warning "Native accessibility test scratch was not removed: $scratch ($($_.Exception.Message))" + break + } + Start-Sleep -Milliseconds 500 + } } - Start-Sleep -Milliseconds 500 } - } } diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index 758c59cb..3bd00cb3 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -1,5 +1,6 @@ { "units": [ + { "id": "ui.navigation.headless-runner-plan", "owner": "Ui", "minimumCount": 14, "exactCount": 14, "requiredGroups": [] }, { "id": "ui.cards-pilot.target-valid", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.foreign-project", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.missing-target", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs index dad571b4..a9fccb2a 100644 --- a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using GraphCode.Validation.Adapters; namespace GraphCode.Validation.Ui; @@ -10,7 +11,10 @@ public static class SuiteRegistration { public static IReadOnlyList CreateSuites() => new IValidationSuite[] { - new ActivationSuite(), new ManagedUiSuite(), new CardsAcceptanceControlsSuite(), new NativeUiSuite(), new ControlClickTopologySuite() + new ActivationSuite(), new ManagedUiSuite(), new CardsAcceptanceControlsSuite(), new NativeUiSuite(), new ControlClickTopologySuite(), + new StandaloneSuites.CaseScriptSuite("ui.navigation-runner-contracts", + @"Tools\windows\Tests\AccessibilityProvider.Native.Tests.Harness.Tests.ps1", + "UI_CASE_RESULT=", new[] { "ui.navigation.headless-runner-plan" }, new[] { "-PlanOnly" }) }.Concat(UiAdapterCases.CreateSuites()).ToArray(); } diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index 3a60818a..228fb010 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -1311,6 +1311,11 @@ function Invoke-Task([string] $name) { } # The native UIA provider suite is not sharded; shard 0 runs it once. if ($ShellTestShard -eq 0) { + Invoke-Native "Headless navigation selection provider contracts" { + & pwsh -NoProfile -File ` + (Join-Path $repoRoot "Tools\windows\Tests\AccessibilityProvider.Native.Tests.ps1") ` + -HeadlessSelection + } Invoke-Native "Native accessibility provider tests" { & pwsh -NoProfile -File ` (Join-Path $repoRoot "Tools\windows\Tests\AccessibilityProvider.Native.Tests.ps1") diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index ef95066e..6f1f4f73 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -28,6 +28,8 @@ The Windows port must have runnable commands before implementation fleets begin. | Release publishing: signing gates, asset labeling, workflow contract | `pwsh Tools\windows\Tests\Release.Tests.ps1` | | Product/investigation provider pin consistency | `pwsh Tools\windows\Tests\ProviderPins.Tests.ps1` | | Native accessibility provider (MSVC and Windows SDK) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.ps1` | +| Navigation selection without desktop/UIA entrypoints (intercepted C++ branch required) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.ps1 -HeadlessSelection` (179 checks; retains compiler/test scratch; separate from the default native 280-check suite) | +| Navigation runner compile-plan controls (no compiler/GUI execution) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.Harness.Tests.ps1 -PlanOnly` (14 source/data checks; compiled `ui.navigation-runner-contracts`) | | Native accessibility harness failure attribution (mock tools, no native fixture) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.Harness.Tests.ps1` | | Native release feed, URL handoff, and allocation safety | `pwsh Tools\windows\Tests\WindowsShell.Tests.ps1 -ZigExecutable $env:GRAPHCODE_ZIG0152` (includes updater tests) | | Successful-update rollback cleanup, locked retry, and failed-recovery preservation | `pwsh Tools\windows\Tests\WindowsShell.Tests.ps1 -ZigExecutable $env:GRAPHCODE_ZIG0152` (includes `WindowsRollbackCleanup.zig`) | From 4b1c5fea202b3804d51a4cc7388539a2ad087e5f Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 9 Nov 2026 11:24:52 -0700 Subject: [PATCH 04/16] Retain original suspended launch custody in the default UIA gate Reuse the existing shell scope and capture pump for live create-new logs, preserve shared cleanup budgets and UNKNOWN retention, and exercise actual dispatch with owned non-GUI children. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit e0be8fee56f0af798513112c4a0335ceaf93816b) --- Tools/windows/ShellOwnedProcess.cs | 19 +- Tools/windows/ShellProcessOwnership.psm1 | 32 ++- Tools/windows/Tests/UiaHeldLauncher.Tests.ps1 | 264 ++++++++++++++++++ Tools/windows/Tests/WindowsShell.Tests.ps1 | 5 + .../Adapters/StandaloneSuites.cs | 2 + .../ValidationRunner/Manifests/host.json | 2 + .../Ownership/Adapters/Capture.Contracts.ps1 | 37 ++- Tools/windows/ValidationRunner/README.md | 23 ++ Tools/windows/uia-live-gate.ps1 | 83 +++++- Tools/windows/validation-matrix.md | 17 +- 10 files changed, 447 insertions(+), 37 deletions(-) create mode 100644 Tools/windows/Tests/UiaHeldLauncher.Tests.ps1 diff --git a/Tools/windows/ShellOwnedProcess.cs b/Tools/windows/ShellOwnedProcess.cs index 710edf87..7158b9ac 100644 --- a/Tools/windows/ShellOwnedProcess.cs +++ b/Tools/windows/ShellOwnedProcess.cs @@ -141,6 +141,12 @@ static string Quote(string value) { } public static ShellOwnedLaunch Suspended(ProcessStartInfo info) { + return Suspended(info, null, null); + } + + public static ShellOwnedLaunch Suspended(ProcessStartInfo info, TextWriter standardOutputWriter, + TextWriter standardErrorWriter) { + // Supplied writers remain caller-owned until verified teardown and pipe EOF. if (info.UseShellExecute || !String.IsNullOrEmpty(info.Arguments)) throw new InvalidOperationException("Owned launch requires UseShellExecute=false and ArgumentList."); var security = new Security { Length = Marshal.SizeOf(), Inherit = 1 }; @@ -202,8 +208,10 @@ public static ShellOwnedLaunch Suspended(ProcessStartInfo info) { run.errorReader = new StreamReader(new FileStream( new Microsoft.Win32.SafeHandles.SafeFileHandle(errRead, true), FileAccess.Read)); errRead = IntPtr.Zero; - run.outputCapture = new OutputCapture(run.outputReader, ForwardWriter(info.RedirectStandardOutput, Console.Out)); - run.errorCapture = new OutputCapture(run.errorReader, ForwardWriter(info.RedirectStandardError, Console.Error)); + run.outputCapture = new OutputCapture(run.outputReader, + standardOutputWriter ?? ForwardWriter(info.RedirectStandardOutput, Console.Out)); + run.errorCapture = new OutputCapture(run.errorReader, + standardErrorWriter ?? ForwardWriter(info.RedirectStandardError, Console.Error)); run.Output = run.outputCapture.Completion; run.Error = run.errorCapture.Completion; return run; @@ -271,6 +279,13 @@ public void Drain() { foreach (var task in new[] { Output, Error }) if (task != null && !task.Wait(5000)) throw new InvalidOperationException("Owned output EOF is unproved."); } + public void Drain(int timeoutMilliseconds) { + if (timeoutMilliseconds < 0) throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds)); + var clock = Stopwatch.StartNew(); + foreach (var task in new[] { Output, Error }) + if (task != null && !task.Wait(Math.Max(0, timeoutMilliseconds - (int)clock.ElapsedMilliseconds))) + throw new InvalidOperationException("Owned output EOF is unproved."); + } public void Close() { if (Handle != IntPtr.Zero) RequireExited(); Drain(); diff --git a/Tools/windows/ShellProcessOwnership.psm1 b/Tools/windows/ShellProcessOwnership.psm1 index eca3c953..d93346ff 100644 --- a/Tools/windows/ShellProcessOwnership.psm1 +++ b/Tools/windows/ShellProcessOwnership.psm1 @@ -45,8 +45,9 @@ function Invoke-ShellJob([string] $Operation, [IntPtr] $Job = [IntPtr]::Zero, } } -function New-ShellSuspendedLaunch([Diagnostics.ProcessStartInfo] $StartInfo) { - [ShellOwnedLaunch]::Suspended($StartInfo) +function New-ShellSuspendedLaunch([Diagnostics.ProcessStartInfo] $StartInfo, + [IO.TextWriter] $StandardOutputWriter, [IO.TextWriter] $StandardErrorWriter) { + [ShellOwnedLaunch]::Suspended($StartInfo, $StandardOutputWriter, $StandardErrorWriter) } function Test-ShellOwnershipUnproven($Failure) { @@ -219,9 +220,13 @@ function New-ShellProcessScope { } } -function Start-ShellOwnedProcess($Scope, [Diagnostics.ProcessStartInfo] $StartInfo) { +function Start-ShellOwnedProcess($Scope, [Diagnostics.ProcessStartInfo] $StartInfo, + [IO.TextWriter] $StandardOutputWriter, [IO.TextWriter] $StandardErrorWriter) { if ($Scope.Closed) { throw "Owned shell scope is closed" } - try { $run = New-ShellSuspendedLaunch $StartInfo } catch { + try { + $run = New-ShellSuspendedLaunch $StartInfo ` + -StandardOutputWriter $StandardOutputWriter -StandardErrorWriter $StandardErrorWriter + } catch { if (Test-ShellOwnershipUnproven $_) { throw (Set-ShellOwnershipUnproven $_.Exception -FailureRecord $_) } @@ -310,18 +315,23 @@ function Receive-ShellOwnedOutput($Scope, [Diagnostics.Process] $Process, } } -function Complete-ShellLaunch($Run) { +function Complete-ShellLaunch($Run, [Nullable[int]] $TimeoutMilliseconds) { if ($Run.Finalized) { return } + $clock = [Diagnostics.Stopwatch]::StartNew() try { $Run.RequireExited() if ($Run.LaunchJob -ne [IntPtr]::Zero -and -not $Run.LaunchJobClosed) { Invoke-ShellJob "Terminate" $Run.LaunchJob - Invoke-ShellJob "Wait" $Run.LaunchJob + $remaining = if ($null -eq $TimeoutMilliseconds) { 5000 } else { + [Math]::Max(0, [int]$TimeoutMilliseconds - [int]$clock.ElapsedMilliseconds) + } + Invoke-ShellJob "Wait" $Run.LaunchJob -TimeoutMilliseconds $remaining if ((Invoke-ShellJob "Active" $Run.LaunchJob) -ne 0) { throw "OWNED_CLEANUP_UNPROVEN: private launch job not empty" } } - $Run.Drain() + if ($null -eq $TimeoutMilliseconds) { $Run.Drain() } + else { $Run.Drain([Math]::Max(0, [int]$TimeoutMilliseconds - [int]$clock.ElapsedMilliseconds)) } if ($Run.LaunchJob -ne [IntPtr]::Zero -and -not $Run.LaunchJobClosed) { Invoke-ShellJob "Close" $Run.LaunchJob $Run.LaunchJobClosed = $true @@ -346,22 +356,22 @@ function Complete-ShellOwnedProcess($Scope, [Diagnostics.Process] $Process) { Complete-ShellLaunch $runs[0] } -function Close-ShellProcessScope($Scope) { +function Close-ShellProcessScope($Scope, [ValidateRange(0, 5000)][int] $TimeoutMilliseconds = 5000) { if ($Scope.Closed) { return } $exitClock = [Diagnostics.Stopwatch]::StartNew() try { Invoke-ShellJob "Terminate" $Scope.Job - Invoke-ShellJob "Wait" $Scope.Job -TimeoutMilliseconds ([Math]::Max(0, 5000 - [int]$exitClock.ElapsedMilliseconds)) + Invoke-ShellJob "Wait" $Scope.Job -TimeoutMilliseconds ([Math]::Max(0, $TimeoutMilliseconds - [int]$exitClock.ElapsedMilliseconds)) if ((Invoke-ShellJob "Active" $Scope.Job) -ne 0) { throw "OWNED_CLEANUP_UNPROVEN: job not empty" } # Job accounting can reach zero before the original process objects signal. # Prove every held root within the same termination deadline before closing any. foreach ($run in $Scope.Runs) { if ($run.Finalized) { continue } if (-not $run.ScopeAssigned) { $run.Stop() } - $run.RequireExited([Math]::Max(0, 5000 - [int]$exitClock.ElapsedMilliseconds)) + $run.RequireExited([Math]::Max(0, $TimeoutMilliseconds - [int]$exitClock.ElapsedMilliseconds)) } foreach ($run in $Scope.Runs) { - Complete-ShellLaunch $run + Complete-ShellLaunch $run -TimeoutMilliseconds ([Math]::Max(0, $TimeoutMilliseconds - [int]$exitClock.ElapsedMilliseconds)) $run.Close() } foreach ($member in $Scope.Members) { $member.Dispose() } diff --git a/Tools/windows/Tests/UiaHeldLauncher.Tests.ps1 b/Tools/windows/Tests/UiaHeldLauncher.Tests.ps1 new file mode 100644 index 00000000..711d1e07 --- /dev/null +++ b/Tools/windows/Tests/UiaHeldLauncher.Tests.ps1 @@ -0,0 +1,264 @@ +[CmdletBinding()] +param( + [string] $RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path, + [Parameter(Mandatory)][string] $ArtifactRoot, + [string] $AssemblyPath, + [switch] $MockOnly +) + +$ErrorActionPreference = "Stop" +$ArtifactRoot = Join-Path $ArtifactRoot ("uia-held-" + [guid]::NewGuid().ToString("N")) +if (Test-Path -LiteralPath $ArtifactRoot) { throw "Held-launch contracts require a fresh artifact root" } +$null = New-Item -ItemType Directory -Path $ArtifactRoot +$gate = Join-Path $RepositoryRoot "Tools\windows\uia-live-gate.ps1" +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile($gate, [ref]$null, [ref]$errors) +if ($errors.Count) { throw "UIA launch source must parse" } +$definitions = @{} +foreach ($name in @("Start-UiaOwnedProcess", "Complete-UiaHeldCapture", "Register-UiaHeldProcess", + "Complete-MultiProjectCapture", "Stop-UiaOwnedProcessTrees", "New-UiaOwnershipFailure")) { + $found = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + if ($found.Count -eq 1) { $definitions[$name] = $found[0].Extent.Text } +} +if (-not $definitions.ContainsKey("Start-UiaOwnedProcess")) { throw "Actual default UIA launch entry is missing" } +. ([scriptblock]::Create($definitions["Start-UiaOwnedProcess"])) +if ($definitions.ContainsKey("Complete-UiaHeldCapture")) { + . ([scriptblock]::Create($definitions["Complete-UiaHeldCapture"])) +} +foreach ($name in @("Complete-MultiProjectCapture", "Stop-UiaOwnedProcessTrees", "New-UiaOwnershipFailure")) { + . ([scriptblock]::Create($definitions[$name])) +} +$results = [Collections.Generic.List[object]]::new() +function Assert-Held([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw $Message } +} +function Read-HeldLog([string] $Path) { + return [string](Get-Content -LiteralPath $Path -Raw) +} +function Invoke-HeldCase([string] $Name, [scriptblock] $Action) { + $failure = $null + try { & $Action } catch { $failure = $_ } + if ($Name.StartsWith("actual-", [StringComparison]::Ordinal) -and $failure -and + (Test-ShellOwnershipUnproven $failure)) { + try { Write-Host "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" } + finally { [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) } + } + $results.Add([pscustomobject]@{ + name = $Name; passed = $null -eq $failure + error = $(if ($failure) { $failure.ToString() + "`n" + $failure.ScriptStackTrace } else { "" }) + }) +} +function Get-Process { throw "PID reopening must not occur in the default launch entry" } +function Start-MultiProjectOwnedProcess { throw "Legacy PID-file launcher was invoked" } +function New-ShellProcessScope { + $state.events.Add("scope") + return [pscustomobject]@{ Closed = $false } +} +function Start-ShellOwnedProcess($Scope, $StartInfo, $StandardOutputWriter, $StandardErrorWriter) { + $state.events.Add("start") + $state.info = $StartInfo + $state.output = $StandardOutputWriter + $state.error = $StandardErrorWriter + Assert-Held ($script:UiaGateOwnedCaptures.Count -eq 1) "Scope was not registered before launch" + if ($state.startFailure) { throw $state.startFailure } + return $state.process +} +function Register-UiaHeldProcess($Process) { + $state.events.Add("register") + Assert-Held ([object]::ReferenceEquals($Process, $state.process)) "Registration adopted a different process" + if ($state.registerFailure) { throw $state.registerFailure } +} +function Close-ShellProcessScope($Scope, [int] $TimeoutMilliseconds) { + $state.events.Add("close") + $state.closeBudget = $TimeoutMilliseconds + if ($state.closeFailure) { throw $state.closeFailure } + $Scope.Closed = $true +} +function Test-ShellOwnershipUnproven($Failure) { + $exception = if ($Failure -is [Management.Automation.ErrorRecord]) { $Failure.Exception } else { $Failure } + return $exception.Data["GraphCodeValidationRootRetentionRequired"] -eq $true +} +function Set-ShellOwnershipUnproven([Exception] $Exception) { + $Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + return $Exception +} + +$mockCases = @("original-reference", "hidden", "derived-output", "output-collision", + "error-collision", "start-failure", "registration-failure", "unknown-start", + "unknown-close", "unknown-no-retry", "streaming", "clean-close", "cleanup-budget") +foreach ($case in $mockCases) { + Invoke-HeldCase $case { + $caseRoot = Join-Path $ArtifactRoot $case + $null = New-Item -ItemType Directory -Path $caseRoot + $out = Join-Path $caseRoot "shell-stdout.log" + $err = Join-Path $caseRoot "shell-stderr.log" + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + $state = @{ + events = [Collections.Generic.List[string]]::new(); process = [Diagnostics.Process]::new() + startFailure = $null; registerFailure = $null; closeFailure = $null + } + $primary = [IO.IOException]::new("original controlled failure") + if ($case -cin @("start-failure", "unknown-start")) { $state.startFailure = $primary } + if ($case -ceq "registration-failure") { $state.registerFailure = $primary } + if ($case -ceq "unknown-start") { $primary.Data["GraphCodeValidationRootRetentionRequired"] = $true } + if ($case -cin @("unknown-close", "unknown-no-retry")) { $state.closeFailure = $primary } + if ($case -ceq "output-collision") { [IO.File]::WriteAllText($out, "retained") } + if ($case -ceq "error-collision") { [IO.File]::WriteAllText($err, "retained") } + $failure = $null + $actual = $null + try { + $actual = Start-UiaOwnedProcess -file "C:\controlled\fixture.exe" ` + -arguments @("space value", "quote`"value", "unicode-$([char]0x3bb)") ` + -standardOutputPath $(if ($case -ceq "derived-output") { "" } else { $out }) ` + -standardErrorPath $err -Hidden:($case -ceq "hidden") + } catch { $failure = $_ } + if ($case -cin @("output-collision", "error-collision")) { + Assert-Held ($null -ne $failure -and $failure.Exception.GetBaseException() -is [IO.IOException] -and + -not $state.events.Contains("start")) "Output collision launched a target or failed for an unrelated reason" + $collision = if ($case -ceq "output-collision") { $out } else { $err } + Assert-Held ([IO.File]::ReadAllText($collision) -ceq "retained") "Output collision overwrote retained bytes" + return + } + if ($case -cin @("start-failure", "registration-failure", "unknown-start")) { + Assert-Held ($failure -and [object]::ReferenceEquals($failure.Exception, $primary)) "Launch replaced its primary failure" + Assert-Held ($state.events.Contains("close") -eq ($case -cne "unknown-start")) "Wrong failed-launch custody release" + if ($case -ceq "unknown-start") { + Assert-Held (-not $script:UiaGateOwnedCaptures[0].cleaned) "Unknown startup discarded its registered scope" + $state.output.Write("retained-after-start-failure") + } + return + } + Assert-Held (-not $failure -and [object]::ReferenceEquals($actual, $state.process)) "Default launch did not return its original held process" + Assert-Held (($state.info.ArgumentList -join "|") -ceq "space value|quote`"value|unicode-$([char]0x3bb)") "Argument binding changed" + Assert-Held ($state.info.WorkingDirectory -ceq (Get-Location).ProviderPath -and + -not $state.info.UseShellExecute -and $state.info.RedirectStandardOutput -and + $state.info.RedirectStandardError -and $state.info.CreateNoWindow -eq ($case -ceq "hidden")) "Launch settings changed" + Assert-Held (($state.events -join ",") -ceq "scope,start,register") "Wrong launch registration order" + if ($case -ceq "streaming") { + $state.output.Write("stdout-live") + $state.error.Write("stderr-live") + Assert-Held ((Read-HeldLog $out) -ceq "stdout-live" -and + (Read-HeldLog $err) -ceq "stderr-live") "Logs were not visible while the target was live" + } + $capture = $script:UiaGateOwnedCaptures[0] + $cleanupFailure = $null + try { + if ($case -ceq "cleanup-budget") { $null = Complete-MultiProjectCapture $capture $caseRoot 137 } + else { $null = Complete-UiaHeldCapture $capture } + } catch { $cleanupFailure = $_ } + if ($case -cin @("unknown-close", "unknown-no-retry")) { + Assert-Held ($cleanupFailure -and (Test-ShellOwnershipUnproven $cleanupFailure) -and -not $capture.cleaned) "Unknown teardown released custody" + $state.output.Write("retained-writer") + if ($case -ceq "unknown-no-retry") { + $repeatFailure = $null + try { $null = Complete-UiaHeldCapture $capture } catch { $repeatFailure = $_ } + Assert-Held ($repeatFailure -and + [object]::ReferenceEquals($repeatFailure.Exception, $cleanupFailure.Exception) -and + @($state.events | Where-Object { $_ -ceq "close" }).Count -eq 1) "Unknown teardown was retried or returned success" + } + } else { + Assert-Held (-not $cleanupFailure -and $capture.cleaned -and $capture.scope.Closed) "Clean teardown did not finalize scope" + Assert-Held ($state.closeBudget -eq $(if ($case -ceq "cleanup-budget") { 137 } else { 5000 })) "Cleanup dispatch reset its original budget" + $disposed = $false + try { $state.output.Write("after-close") } catch [ObjectDisposedException] { $disposed = $true } + Assert-Held $disposed "Output writer survived verified cleanup" + } + } +} + +if (-not $MockOnly -and @($results | Where-Object { -not $_.passed }).Count -eq 0) { + Import-Module (Join-Path $RepositoryRoot "Tools\windows\ShellProcessOwnership.psm1") -Force + $env:GRAPHCODE_VALIDATION_ASSEMBLY = $AssemblyPath + & (Join-Path $RepositoryRoot "Tools\windows\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $AssemblyPath -RequiredType "ShellOwnedLaunch" | Out-Null + . ([scriptblock]::Create($definitions["Register-UiaHeldProcess"])) + function New-ShellProcessScope { ShellProcessOwnership\New-ShellProcessScope } + function Start-ShellOwnedProcess($Scope, $StartInfo, $StandardOutputWriter, $StandardErrorWriter) { + ShellProcessOwnership\Start-ShellOwnedProcess $Scope $StartInfo ` + -StandardOutputWriter $StandardOutputWriter -StandardErrorWriter $StandardErrorWriter + } + function Close-ShellProcessScope($Scope, [int] $TimeoutMilliseconds) { + ShellProcessOwnership\Close-ShellProcessScope $Scope -TimeoutMilliseconds $TimeoutMilliseconds + } + function Test-ShellOwnershipUnproven($Failure) { ShellProcessOwnership\Test-ShellOwnershipUnproven $Failure } + function Set-ShellOwnershipUnproven([Exception] $Exception) { + ShellProcessOwnership\Set-ShellOwnershipUnproven $Exception + } + foreach ($case in @("actual-streams-and-binding", "actual-exit-failure", "actual-start-failure")) { + Invoke-HeldCase $case { + $caseRoot = Join-Path $ArtifactRoot $case + $null = New-Item -ItemType Directory -Path $caseRoot + $out = Join-Path $caseRoot "shell-stdout.log" + $err = Join-Path $caseRoot "shell-stderr.log" + $fixture = Join-Path $caseRoot "child.ps1" + [IO.File]::WriteAllText($fixture, @' +param([string] $Expected, [string] $Release) +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +[Console]::WriteLine("BINDING=" + $Expected + "|" + (Get-Location).ProviderPath + "|" + $env:GRAPHCODE_HELD_TEST) +[Console]::Out.WriteLine("o" * 100000) +[Console]::Error.WriteLine("e" * 100000) +[Console]::Out.WriteLine("READY") +[Console]::Out.Flush() +if ($env:GRAPHCODE_HELD_TEST -ceq "exit-seven") { exit 7 } +$clock = [Diagnostics.Stopwatch]::StartNew() +while (-not [IO.File]::Exists($Release)) { + if ($clock.ElapsedMilliseconds -gt 10000) { exit 9 } + [Threading.Thread]::Sleep(10) +} +'@, [Text.UTF8Encoding]::new($false)) + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + $env:GRAPHCODE_HELD_TEST = if ($case -ceq "actual-exit-failure") { "exit-seven" } else { "present" } + $release = Join-Path $caseRoot "release" + $value = "space quote`" unicode-$([char]0x3bb)" + $file = if ($case -ceq "actual-start-failure") { Join-Path $caseRoot "missing.exe" } else { (Get-Command pwsh).Source } + $failure = $null + $process = $null + try { + try { + $process = Start-UiaOwnedProcess -file $file -arguments @("-NoProfile", "-File", $fixture, $value, $release) ` + -standardOutputPath $out -standardErrorPath $err -Hidden + } catch { $failure = $_ } + if ($case -ceq "actual-start-failure") { + Assert-Held ($failure -and $script:UiaGateOwnedCaptures[0].cleaned) "Failed CreateProcess did not clean its registered empty scope" + } else { + Assert-Held (-not $failure) "Actual held child failed to launch: $failure" + $clock = [Diagnostics.Stopwatch]::StartNew() + while (-not (([string](Read-HeldLog $out)).Contains("READY") -and + ([string](Read-HeldLog $err)).Contains("e" * 100000))) { + if ($clock.ElapsedMilliseconds -gt 5000) { throw "Actual child did not stream READY before deadline" } + [Threading.Thread]::Sleep(10) + } + $text = Read-HeldLog $out + Assert-Held ($text.Contains("BINDING=$value|$((Get-Location).ProviderPath)|$env:GRAPHCODE_HELD_TEST") -and + $text.Contains("o" * 100000) -and (Read-HeldLog $err).Contains("e" * 100000)) "Actual argument/environment/directory/concurrent streams differ" + if ($case -ceq "actual-streams-and-binding") { + Assert-Held (-not $process.HasExited) "Streaming evidence was collected only after exit" + [IO.File]::WriteAllText($release, "release") + } + Assert-Held ($process.WaitForExit(5000)) "Actual child did not exit before deadline" + Assert-Held ($process.ExitCode -eq $(if ($case -ceq "actual-exit-failure") { 7 } else { 0 })) "Actual child exit was masked" + } + } finally { + Stop-UiaOwnedProcessTrees @($process) + } + } + } +} +$failed = @($results | Where-Object { -not $_.passed }) +[IO.File]::WriteAllText((Join-Path $ArtifactRoot "cases.json"), ($results | ConvertTo-Json -Depth 6), [Text.UTF8Encoding]::new($false)) +$expected = if ($MockOnly) { 13 } else { 16 } +$complete = $results.Count -eq $expected -and $failed.Count -eq 0 -and + @($results.name | Sort-Object -Unique).Count -eq $expected +Write-Host "UIA held launcher: $($results.Count) cases, $($failed.Count) failures; expected=$expected" +foreach ($failure in $failed) { Write-Host "$($failure.name): $($failure.error)" } +if (-not $MockOnly) { + Write-Output ("HOST_CASE_RESULT=" + ([ordered]@{ + id = "host.uia-held-launcher"; status = $(if ($complete) { "Passed" } else { "Failed" }) + executedCount = $results.Count; groups = @($results | Where-Object passed | ForEach-Object name) + evidence = "Actual default launch dispatch and existing held scope; mock refusals plus three owned non-GUI child cases; UIA calls=0" + } | ConvertTo-Json -Compress)) +} +if (-not $complete) { exit 1 } diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index afccd011..3325f997 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -890,6 +890,11 @@ Invoke-Native "Owned live caller and adapter mock integration" { throw "Owned live caller and adapter mock integration did not pass all 13 cases" } Write-Host "OWNED_LIVE_ADAPTER_MOCK_CASES=$($adapterResults.PassedCount)" + $heldAssembly = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") + & pwsh -NoProfile -File (Join-Path $PSScriptRoot "UiaHeldLauncher.Tests.ps1") ` + -ArtifactRoot (Join-Path $repoRoot ".build\uia-held-launcher-shell") ` + -AssemblyPath $heldAssembly + if ($LASTEXITCODE -ne 0) { throw "Default UIA held-launch contracts failed" } $global:LASTEXITCODE = 0 } Invoke-Native "GDI+ asynchronous startup live test" { diff --git a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs index b8a7d82a..bdbf4431 100644 --- a/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs +++ b/Tools/windows/ValidationRunner/Adapters/StandaloneSuites.cs @@ -46,6 +46,8 @@ public static IReadOnlyList Create(IReadOnlyList "HOST_CASE_RESULT=", ["host.provider-build"]), new CaseScriptSuite("host.legacy-ownership.shell-scope", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", "HOST_CASE_RESULT=", ["host.legacy-ownership.shell-scope"], ["-Contract", "shell-scope"]), + new CaseScriptSuite("host.uia-held-launcher", "Tools\\windows\\Tests\\UiaHeldLauncher.Tests.ps1", + "HOST_CASE_RESULT=", ["host.uia-held-launcher"]), new CaseScriptSuite("host.legacy-ownership.live-adapter", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", "HOST_CASE_RESULT=", ["host.legacy-ownership.live-adapter"], ["-Contract", "live-adapter"]), new CaseScriptSuite("host.compiled-assembly-environment", "Tools\\windows\\ValidationRunner\\Adapters\\LegacyOwnership.Contracts.ps1", diff --git a/Tools/windows/ValidationRunner/Manifests/host.json b/Tools/windows/ValidationRunner/Manifests/host.json index 83bfec9f..e8e977a6 100644 --- a/Tools/windows/ValidationRunner/Manifests/host.json +++ b/Tools/windows/ValidationRunner/Manifests/host.json @@ -1,5 +1,7 @@ { "units": [ + { "id": "host.uia-held-launcher", "owner": "Host", "minimumCount": 16, "exactCount": 16, + "requiredGroups": ["actual-streams-and-binding", "actual-exit-failure", "actual-start-failure"] }, { "id": "host.sdk-prerequisites", "owner": "Host", "minimumCount": 49, "exactCount": 49 }, { "id": "host.admission-guards", "owner": "Host", "minimumCount": 65, "exactCount": 65 }, { "id": "host.compiled-assembly-environment", "owner": "Host", "minimumCount": 7, "exactCount": 7 }, diff --git a/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 index 9821d36c..e6650fb5 100644 --- a/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 +++ b/Tools/windows/ValidationRunner/Ownership/Adapters/Capture.Contracts.ps1 @@ -16,7 +16,7 @@ $jobReplacements = @{ '[GraphCode.Validation.Ownership.StandaloneProcessJob]' = '[GraphCode.Validation.Ownership.OwnershipJobFixture]' } . Import-OwnershipProductionFunctions $gate @('New-UiaOwnershipFailure', 'Complete-MultiProjectCapture', - 'Start-UiaOwnedProcess', 'Invoke-UiaCleanupActions') $jobReplacements + 'Start-UiaOwnedProcess', 'Complete-UiaHeldCapture', 'Invoke-UiaCleanupActions') $jobReplacements $saved = [Environment]::GetEnvironmentVariables('Process') Remove-OwnershipEnvironment 'GRAPHCODE_VALIDATION_ROOT' try { @@ -126,26 +126,39 @@ try { } } finally { $reader.Dispose() } $launch = @{} - function Start-MultiProjectOwnedProcess { - param($file, $arguments, $environment, $stem, [switch]$Visible, [switch]$RegisterForUia, - $standardOutputPath, $standardErrorPath, $logDirectory, $targetWorkingDirectory) - $launch.arguments = $arguments; $launch.environment = $environment - $launch.visible = $Visible; $launch.registered = $RegisterForUia - $launch.cwd = $targetWorkingDirectory; $launch.stdout = $standardOutputPath - return @{ process = 'controlled-owned-target' } + $controlled = [Diagnostics.Process]::new() + function New-ShellProcessScope { return [pscustomobject]@{ Closed = $false } } + function Start-ShellOwnedProcess($Scope, $StartInfo, $StandardOutputWriter, $StandardErrorWriter) { + $launch.arguments = $StartInfo.ArgumentList; $launch.environment = $StartInfo.Environment + $launch.visible = -not $StartInfo.CreateNoWindow + $launch.cwd = $StartInfo.WorkingDirectory + $launch.stdout = $StandardOutputWriter.BaseStream.Name + Assert-Ownership ($script:UiaGateOwnedCaptures.Count -eq 1) 'Held scope was not registered before launch.' + return $controlled + } + function Register-UiaHeldProcess($Process) { $launch.registered = [Object]::ReferenceEquals($Process, $controlled) } + function Close-ShellProcessScope($Scope, [int] $TimeoutMilliseconds) { + Assert-Ownership ($TimeoutMilliseconds -eq 5000) 'Default capture reset its cleanup budget.' + $Scope.Closed = $true } foreach ($hidden in @($false, $true)) { Invoke-OwnershipCase "owned-routing-hidden-$hidden" { - $stderr = Join-Path $ArtifactRoot 'fixture-stderr.log' - $result = Start-UiaOwnedProcess -file 'controlled.exe' -arguments @('space argument', '', '"literal"') -standardErrorPath $stderr -Hidden:$hidden - Assert-Ownership ($result -ceq 'controlled-owned-target' -and $launch.arguments.Count -eq 3 -and + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + $stderr = Join-Path $ArtifactRoot "fixture-$hidden-stderr.log" + try { + $result = Start-UiaOwnedProcess -file 'controlled.exe' -arguments @('space argument', '', '"literal"') -standardErrorPath $stderr -Hidden:$hidden + Assert-Ownership ([Object]::ReferenceEquals($result, $controlled) -and $launch.arguments.Count -eq 3 -and $launch.arguments[0] -ceq 'space argument' -and $launch.arguments[1] -ceq '' -and $launch.arguments[2] -ceq '"literal"' -and $launch.visible -eq (-not $hidden) -and $launch.registered -and $launch.cwd -ceq (Get-Location).ProviderPath -and $launch.environment.TEMP -ceq $env:TEMP -and - $launch.stdout -ceq (Join-Path $ArtifactRoot 'fixture-stdout.log')) 'Actual launch wrapper changed argv/environment/cwd/visibility/stdio/registry routing.' + $launch.stdout -ceq (Join-Path $ArtifactRoot "fixture-$hidden-stdout.log")) 'Actual launch wrapper changed argv/environment/cwd/visibility/stdio/registry routing.' + } finally { + foreach ($capture in $script:UiaGateOwnedCaptures) { $null = Complete-UiaHeldCapture $capture } + } } } + $controlled.Dispose() Invoke-OwnershipCase 'actual-assignment-membership-retain-order' { $definition = $gate.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Start-MultiProjectOwnedProcess' diff --git a/Tools/windows/ValidationRunner/README.md b/Tools/windows/ValidationRunner/README.md index 3d2392b8..bb2e32bf 100644 --- a/Tools/windows/ValidationRunner/README.md +++ b/Tools/windows/ValidationRunner/README.md @@ -113,6 +113,29 @@ proof only from validated results of the current invocation. Its derived ## Production and process boundaries +The default UIA launch adapter has a separate mandatory +`host.uia-held-launcher` unit: 16 unique cases, comprising 13 mocked refusals, +bindings and cleanup controls plus three actual owned non-GUI child cases. +The actual cases exercise the production default launch/registration and +cleanup dispatch with concurrent live stdout/stderr, Unicode argv, environment +and working-directory propagation, a nonzero exit, and failed process creation. +They do not load a UIA clientside provider or create/query a window. The +optional writer overload uses the existing capture pump; writers stay owned by +the adapter until job/root exit and both pipe EOFs are proved. Partial setup +preserves the original error, existing evidence is never overwritten, and +UNKNOWN retains authority rather than closing or retrying the scope. + +The original shell-ownership unit remains exactly 77 cases with seven diagnostic +groups; the caller adapter remains 13 and capture routing remains 14. These +regressions are distinct from the new launch unit. The selected canonical +invocation can name `host.uia-held-launcher`, +`host.legacy-ownership.shell-scope`, `host.legacy-ownership.live-adapter` and +`ownership.capture` together. It deliberately remains an incomplete invocation, +not the full CI gate. The shared regression also selected the independent Jump +functional and navigation-runner adapters (70 units, 256 counted executions on +the combined 687-unit inventory); an issue candidate must select only suites +present in its own issue-specific tree. + Production C# files are linked into `WindowsValidationBindings.csproj` once. UIA, packaging contracts, shell ownership, and managed/native tests consume those same implementations. The loader verifies the exact file/type, worktree, diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 18d9e334..35176d4b 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -4971,6 +4971,9 @@ function Wait-MultiProjectCapture($capture, [int] $timeoutMilliseconds = 5000) { function Complete-MultiProjectCapture($capture, [string] $logDirectory, [int] $timeoutMilliseconds = 5000, $primaryError = $null) { if ($null -eq $capture) { return } + if ($capture -is [Collections.IDictionary] -and $capture.Contains("scope")) { + return Complete-UiaHeldCapture $capture -TimeoutMilliseconds $timeoutMilliseconds + } $clock = [Diagnostics.Stopwatch]::StartNew() $errors = [Collections.Generic.List[string]]::new() $identity = "pid=$($capture.rootPID) start=$($capture.rootStartUtcTicks) target=$($capture.targetPID) targetStart=$($capture.targetStartUtcTicks) job=$($capture.job.ToInt64())" @@ -5064,17 +5067,81 @@ function Start-UiaOwnedProcess( [string] $file, [string[]] $arguments, [string] $standardOutputPath, [string] $standardErrorPath, [switch] $Hidden ) { - $environment = @{} - foreach ($entry in [Environment]::GetEnvironmentVariables("Process").GetEnumerator()) { - $environment[$entry.Key] = $entry.Value - } $directory = Split-Path -Parent $standardErrorPath $stem = [IO.Path]::GetFileNameWithoutExtension($standardErrorPath) -replace "-stderr$", "" if (-not $standardOutputPath) { $standardOutputPath = Join-Path $directory "$stem-stdout.log" } - $capture = Start-MultiProjectOwnedProcess -file $file -arguments $arguments -environment $environment ` - -stem $stem -Visible:(-not $Hidden) -RegisterForUia -standardOutputPath $standardOutputPath ` - -standardErrorPath $standardErrorPath -logDirectory $directory -targetWorkingDirectory (Get-Location).ProviderPath - return $capture.process + $capture = [ordered]@{ + scope = New-ShellProcessScope; process = $null; cleaned = $false + logDirectory = $directory; stdoutWriter = $null; stderrWriter = $null; ownershipFailure = $null + } + if ($null -eq $script:UiaGateOwnedCaptures) { + $script:UiaGateOwnedCaptures = [Collections.Generic.List[object]]::new() + } + $script:UiaGateOwnedCaptures.Add($capture) + try { + foreach ($stream in @( + @{ key = "stdoutWriter"; path = $standardOutputPath }, + @{ key = "stderrWriter"; path = $standardErrorPath } + )) { + $fileStream = [IO.File]::Open($stream.path, [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, [IO.FileShare]::Read) + try { + $capture[$stream.key] = [IO.StreamWriter]::new($fileStream, [Text.UTF8Encoding]::new($false)) + } catch { + $fileStream.Dispose() + throw + } + $capture[$stream.key].AutoFlush = $true + } + $info = [Diagnostics.ProcessStartInfo]::new($file) + $info.UseShellExecute = $false + $info.CreateNoWindow = [bool]$Hidden + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.WorkingDirectory = (Get-Location).ProviderPath + foreach ($argument in $arguments) { $info.ArgumentList.Add($argument) } + $capture.process = Start-ShellOwnedProcess $capture.scope $info ` + -StandardOutputWriter $capture.stdoutWriter -StandardErrorWriter $capture.stderrWriter + Register-UiaHeldProcess $capture.process + return $capture.process + } catch { + $primary = $_ + if (Test-ShellOwnershipUnproven $primary) { + $capture.ownershipFailure = $primary.Exception + throw + } + try { $null = Complete-UiaHeldCapture $capture } catch { + $primary.Exception.Data["UiaHeldCleanupFailure"] = $_.Exception + if (Test-ShellOwnershipUnproven $_) { + $primary.Exception.Data["GraphCodeValidationRootRetentionRequired"] = $true + } + } + throw $primary + } +} + +function Register-UiaHeldProcess([Diagnostics.Process] $process) { + [GraphCodeUiaGateState]::RetainProcess($process) +} + +function Complete-UiaHeldCapture($capture, [ValidateRange(0, 5000)][int] $TimeoutMilliseconds = 5000) { + if ($capture.ownershipFailure) { throw $capture.ownershipFailure } + if ($capture.cleaned) { return } + try { + Close-ShellProcessScope $capture.scope -TimeoutMilliseconds $TimeoutMilliseconds + } catch { + $capture.ownershipFailure = Set-ShellOwnershipUnproven $_.Exception + $capture.ownershipFailure.Data["UiaRetainedCapture"] = $capture + throw $capture.ownershipFailure + } + $errors = [Collections.Generic.List[Exception]]::new() + foreach ($key in @("stdoutWriter", "stderrWriter")) { + try { if ($capture[$key]) { $capture[$key].Dispose() } } + catch { $errors.Add($_.Exception) } + } + $capture.cleaned = $true + if ($errors.Count) { throw [AggregateException]::new("UIA held output persistence failed", $errors.ToArray()) } + Write-Host "UIA_HELD_CAPTURE_CLEANUP=verified originalScope=true pipeEof=true" } function Start-MultiProjectOwnedProcess( diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 6f1f4f73..8dfe39e4 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -948,10 +948,19 @@ evidence only. Main changes product and native image/window surfaces, so a new integrated source-bound build and separately admitted functional rerun are required before claiming this head is natively qualified. -The normal default UIA gate sets synthetic fixture and -connection-failure flags and uses its identity-file/PID-reopen launcher. It -does not establish real-daemon/session restoration and is unchanged. The new -route returns before that default path and uses `ShellProcessOwnership.psm1` +The normal default UIA gate sets synthetic fixture and connection-failure +flags. Its default and secondary-shell launch entry now uses +`ShellProcessOwnership.psm1`: the original target starts suspended, joins its +scope/private jobs before release, and registers the original generation-checked +process while its native launch handle remains held. Caller-owned UTF-8 writers +tee both independently drained streams into create-new logs before launch. +UNKNOWN retains the scope and writers without retrying cleanup. Cleanup passes +the original remaining budget through scope/job/root/pipe checks; stopwatch +budgets do not guarantee kernel scheduling. The legacy multi-project launcher +is unchanged and still has a separate PID-file path, so this correction must +not be claimed for that route. Neither synthetic path establishes real-daemon +session restoration. The restored-input route returns before that default path +and uses `ShellProcessOwnership.psm1` suspended original launches and retained jobs, private profile/support/temp, explicit child environments with no inherited UIA or synthetic workspace flags, and normal production CLI commands. Since Swift's zmx locator ignores `PATH`, From 768763fbea2ea4fc2fb6ba62751b6dd46efd8e41 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 9 Nov 2026 11:32:04 -0700 Subject: [PATCH 05/16] Require coherent native sidebar navigation selection in shared acceptance Bind all five canonical destination classes to the root selection container and exact runtime identities. Exercise the actual read-only wrapper with audited fake pattern tokens; retain aliases without admitting them into navigation selection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit 8f3b4315783f49481c610e2481c1872a618df42d) --- .../Tests/NavigationSelectionOracle.Tests.ps1 | 187 ++++++++++++++++++ .../ValidationRunner/Manifests/ui.json | 2 + .../ValidationRunner/Ui/SuiteRegistration.cs | 5 +- .../Ui/UiDerivedContracts.ps1 | 2 +- Tools/windows/uia-live-gate.ps1 | 97 ++++++++- Tools/windows/validation-matrix.md | 1 + investigation/ui-parity-matrix.md | 2 +- 7 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 diff --git a/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 b/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 new file mode 100644 index 00000000..e2f5ad62 --- /dev/null +++ b/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 @@ -0,0 +1,187 @@ +[CmdletBinding()] +param( + [string] $RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path, + [Parameter(Mandatory)][string] $ArtifactRoot, + [string] $AssemblyPath +) + +$ErrorActionPreference = "Stop" +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $RepositoryRoot "Tools\windows\uia-live-gate.ps1"), [ref]$null, [ref]$errors) +if ($errors.Count) { throw "Navigation gate must parse" } +foreach ($name in @("Test-UiaCanonicalNavigationId", "Assert-UiaNavigationSelectionSnapshot")) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + if ($definitions.Count -ne 1) { throw "Missing unique navigation observation oracle: $name" } + if ($definitions[0].Extent.Text -match "System.Windows.Automation|GraphCodeUiaGateState|GetCurrentPattern|Invoke\(") { + throw "Headless navigation oracle must not bind native UIA or input" + } + . ([scriptblock]::Create($definitions[0].Extent.Text)) +} +$cases = @("graph", "quick-chats", "project", "loop", "chat", "no-selected", + "two-selected", "empty-container-selection", "duplicate-container-selection", + "wrong-container-selection", "sibling-container", "foreign-row", "duplicate-id", + "duplicate-runtime", "missing-fixed", "foreign-container-selection", "multiple-selection", + "alias", "wrong-container-runtime", "selected-runtime-drift", "foreign-root", + "wrong-root-id", "invalid-selected-type") +$results = [Collections.Generic.List[object]]::new() +foreach ($case in $cases) { + $root = [pscustomobject]@{ id = "graphcode-root"; runtime = "42,0"; process = 42; multiple = $false } + $ids = @("overview-destination", "quick-chats-destination", "open-project-1", "loop-row-2", "quick-chat-row-3") + $expectedIndex = switch ($case) { "quick-chats" { 1 } "project" { 2 } "loop" { 3 } "chat" { 4 } default { 0 } } + $expected = $ids[$expectedIndex] + $rows = @(for ($index = 0; $index -lt $ids.Count; $index++) { + [pscustomobject]@{ + id = $ids[$index]; runtime = "42,$($index + 1)"; process = 42; selected = $index -eq $expectedIndex + containerId = "graphcode-root"; containerRuntime = "42,0"; containerProcess = 42 + } + }) + $selected = @([pscustomobject]@{ id = $expected; runtime = $rows[$expectedIndex].runtime; process = 42 }) + switch ($case) { + "no-selected" { $rows[0].selected = $false } + "two-selected" { $rows[2].selected = $true } + "empty-container-selection" { $selected = @() } + "duplicate-container-selection" { $selected += $selected[0] } + "wrong-container-selection" { $selected[0].id = "open-project-1" } + "sibling-container" { $rows[3].containerId = "projects" } + "foreign-row" { $rows[3].process = 99 } + "duplicate-id" { $rows += $rows[0] } + "duplicate-runtime" { $rows[1].runtime = $rows[0].runtime } + "missing-fixed" { $rows = @($rows | Where-Object id -cne "quick-chats-destination") } + "foreign-container-selection" { $selected[0].process = 99 } + "multiple-selection" { $root.multiple = $true } + "alias" { $rows[3].id = "needs-you-row-2" } + "wrong-container-runtime" { $rows[3].containerRuntime = "42,99" } + "selected-runtime-drift" { $selected[0].runtime = "42,99" } + "foreign-root" { $root.process = 99 } + "wrong-root-id" { $root.id = "projects" } + "invalid-selected-type" { $rows[0].selected = "true" } + } + $failure = $null + try { Assert-UiaNavigationSelectionSnapshot $root $rows $selected 42 $expected } catch { $failure = $_ } + $positive = $case -cin @("graph", "quick-chats", "project", "loop", "chat") + $passed = ($null -eq $failure) -eq $positive + if ($failure -and $failure.Exception.Message -notlike "Navigation selection:*") { $passed = $false } + $results.Add([pscustomobject]@{ + name = $case; passed = $passed + error = $(if (-not $passed) { [string]$failure } else { "" }) + }) +} +$wrappers = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Assert-UiaSingleNavigationSelection" +}, $true)) +if ($wrappers.Count -ne 1) { throw "Missing unique live navigation readback wrapper" } +$calls = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq "Assert-UiaSingleNavigationSelection" +}, $true)) +if ($calls.Count -ne 5) { throw "Live gate must check Graph, Quick Chats, project, loop and active chat selection" } +$derivedAst = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $RepositoryRoot "Tools\windows\ValidationRunner\Ui\UiDerivedContracts.ps1"), + [ref]$null, [ref]$errors) +if ($errors.Count) { throw "Derived UI source contracts must parse" } +foreach ($name in @("Assert-Derived", "Get-Region")) { + $found = @($derivedAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + if ($found.Count -ne 1) { throw "Missing coupled source-boundary evaluator: $name" } + . ([scriptblock]::Create($found[0].Extent.Text)) +} +$gateSource = [IO.File]::ReadAllText((Join-Path $RepositoryRoot "Tools\windows\uia-live-gate.ps1")) +$caption = Get-Region $gateSource '$laneProjectId = Get-MultiProjectAutomationId "overview-project" $fixtureProjectPath' ` + '$quickChatNavigation = Find-FragmentById $root "quick-chats-destination" $rawWalker' +if (-not $caption.Contains('UIA_LANE_NO_WORKTREE_CHIP_EVIDENCE=')) { throw "Coupled caption source region lost its evidence boundary" } +$definition = $wrappers[0].Extent.Text +foreach ($token in @( + '[System.Windows.Automation.SelectionPattern]::Pattern', + '[System.Windows.Automation.SelectionItemPattern]::Pattern' +)) { + if ([regex]::Matches($definition, [regex]::Escape($token)).Count -ne 1) { throw "Unexpected native pattern binding: $token" } +} +$definition = $definition.Replace('[System.Windows.Automation.SelectionPattern]::Pattern', '10'). + Replace('[System.Windows.Automation.SelectionItemPattern]::Pattern', '20') +if ($definition -match "System.Windows.Automation|GraphCodeUiaGateState|\.Select\(|\.Invoke\(") { + throw "Unsubstituted native binding or input in the read-only wrapper" +} +. ([scriptblock]::Create($definition)) +function Require([bool] $Condition, [string] $Message) { if (-not $Condition) { throw $Message } } +function Start-Sleep([int] $Milliseconds) { + if ($Milliseconds -ne 50) { throw "Unexpected wrapper observation delay" } + $binding.sleeps++ +} +function Find-FragmentById($Root, [string] $Id, $Walker) { + if ($Id -cnotin @("projects", "loops")) { throw "Unexpected wrapper fragment" } + return [pscustomobject]@{ Id = $Id } +} +function Get-SidebarNavigationChildren($Root, $Projects, $Walker) { return $binding.elements } +function Get-DirectChildren($Parent, $Walker) { return @() } +foreach ($case in @("live-binding", "live-alias-exclusion", "live-delayed-selection", "live-timeout")) { + $failure = $null + $binding = @{ + reads = 0; sleeps = 0; items = 0 + root = [pscustomobject]@{ Current = [pscustomobject]@{ AutomationId = "graphcode-root"; ProcessId = 42 } } + elements = @() + } + $binding.root | Add-Member ScriptMethod GetRuntimeId { return @(42, 0) } + foreach ($index in 0..4) { + $element = [pscustomobject]@{ + Current = [pscustomobject]@{ AutomationId = $ids[$index]; ProcessId = 42 } + Runtime = @(42, ($index + 1)) + } + $element | Add-Member ScriptMethod GetRuntimeId { return $this.Runtime } + $element | Add-Member ScriptMethod GetCurrentPattern { + param($Pattern) + if ($Pattern -ne 20 -or -not (Test-UiaCanonicalNavigationId $this.Current.AutomationId)) { throw "Unexpected row pattern/alias read" } + $binding.items++ + return [pscustomobject]@{ Current = [pscustomobject]@{ + IsSelected = $this.Current.AutomationId -ceq "overview-destination" + SelectionContainer = $binding.root + } } + } + $binding.elements += $element + } + $current = [pscustomobject]@{ CanSelectMultiple = $false } + $current | Add-Member ScriptMethod GetSelection { + $binding.reads++ + if ($case -ceq "live-timeout" -or ($case -ceq "live-delayed-selection" -and $binding.reads -lt 3)) { return @() } + return @($binding.elements[0]) + } + $binding.selection = [pscustomobject]@{ Current = $current } + $binding.root | Add-Member ScriptMethod GetCurrentPattern { + param($Pattern) + if ($Pattern -ne 10) { throw "Unexpected root selection binding" } + return $binding.selection + } + if ($case -ceq "live-alias-exclusion") { + $binding.elements += [pscustomobject]@{ Current = [pscustomobject]@{ + AutomationId = "needs-you-row-2"; ProcessId = 42 + } } + } + try { Assert-UiaSingleNavigationSelection $binding.root $null "overview-destination" 42 } catch { $failure = $_ } + $expectedReads = switch ($case) { "live-timeout" { 60 } "live-delayed-selection" { 3 } default { 1 } } + $expectedSleeps = switch ($case) { "live-timeout" { 60 } "live-delayed-selection" { 2 } default { 0 } } + $passed = (($null -eq $failure) -eq ($case -cne "live-timeout")) -and + $binding.reads -eq $expectedReads -and $binding.sleeps -eq $expectedSleeps -and $binding.items -eq 5 + if ($failure -and $failure.Exception.Message -notlike "Navigation selection:*") { $passed = $false } + $results.Add([pscustomobject]@{ name = $case; passed = $passed; error = $(if (-not $passed) { [string]$failure } else { "" }) }) +} +$failed = @($results | Where-Object { -not $_.passed }) +$complete = $results.Count -eq 27 -and @($results.name | Sort-Object -Unique).Count -eq 27 -and $failed.Count -eq 0 +$null = New-Item -ItemType Directory -Force -Path $ArtifactRoot +$resultPath = Join-Path $ArtifactRoot ("navigation-oracle-" + [guid]::NewGuid().ToString("N") + ".json") +[IO.File]::WriteAllText($resultPath, ($results | ConvertTo-Json -Depth 6), [Text.UTF8Encoding]::new($false)) +Write-Host "Navigation selection oracle: $($results.Count) cases, $($failed.Count) failures (nativeCalls=0)" +foreach ($failure in $failed) { Write-Host "$($failure.name): $($failure.error)" } +Write-Output ("UI_CASE_RESULT=" + ([ordered]@{ + id = "ui.navigation.selection-snapshot-controls"; status = $(if ($complete) { "Passed" } else { "Failed" }) + executedCount = $results.Count; groups = @($results | Where-Object passed | ForEach-Object name) + evidence = "Actual shared navigation snapshot oracle; source-derived in-memory observations; no UIA import or native calls" +} | ConvertTo-Json -Compress)) +if (-not $complete) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index 3bd00cb3..fa5e344d 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -30,6 +30,8 @@ { "id": "ui.cards-pilot.original-cli-exit-budget", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.completed-cli-ownership-not-identity", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.popup-pointer-preparation", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.navigation.selection-snapshot-controls", "owner": "Ui", "minimumCount": 27, "exactCount": 27, + "requiredGroups": ["graph", "quick-chats", "project", "loop", "chat"] }, { "id": "ui.cards-pilot.zip-source-boundary", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-filename-identity", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-fixture-valid", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs index a9fccb2a..f3119c15 100644 --- a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs @@ -14,7 +14,10 @@ public static class SuiteRegistration new ActivationSuite(), new ManagedUiSuite(), new CardsAcceptanceControlsSuite(), new NativeUiSuite(), new ControlClickTopologySuite(), new StandaloneSuites.CaseScriptSuite("ui.navigation-runner-contracts", @"Tools\windows\Tests\AccessibilityProvider.Native.Tests.Harness.Tests.ps1", - "UI_CASE_RESULT=", new[] { "ui.navigation.headless-runner-plan" }, new[] { "-PlanOnly" }) + "UI_CASE_RESULT=", new[] { "ui.navigation.headless-runner-plan" }, new[] { "-PlanOnly" }), + new StandaloneSuites.CaseScriptSuite("ui.navigation-oracle-contracts", + @"Tools\windows\Tests\NavigationSelectionOracle.Tests.ps1", + "UI_CASE_RESULT=", new[] { "ui.navigation.selection-snapshot-controls" }) }.Concat(UiAdapterCases.CreateSuites()).ToArray(); } diff --git a/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 index 36654cc9..340adce2 100644 --- a/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/UiDerivedContracts.ps1 @@ -54,7 +54,7 @@ function Assert-ContainsAll([string] $Text, [string[]] $Needles) { $caption = $null Invoke-Derived 'ui.root.source.line-5501' { $script:caption = Get-Region $source '$laneProjectId = Get-MultiProjectAutomationId "overview-project" $fixtureProjectPath' ` - '$surfaceActionPatterns["quick-chats-destination"].Invoke()' + '$quickChatNavigation = Find-FragmentById $root "quick-chats-destination" $rawWalker' } Invoke-Derived 'ui.root.source.line-5514' { Assert-Derived ($null -ne $caption) 'Caption region unavailable' diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 35176d4b..fe126675 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2005,6 +2005,84 @@ function Find-FragmentById( return $null } +function Test-UiaCanonicalNavigationId([string] $Id) { + return $Id -cin @("overview-destination", "quick-chats-destination") -or + $Id -cmatch '^(open-project|project-row|loop-row|quick-chat-row)-[0-9]+$' +} + +function Assert-UiaNavigationSelectionSnapshot( + $Root, + [object[]] $Rows, + [object[]] $Selected, + [int] $ProcessId, + [string] $ExpectedId +) { + if ($ProcessId -le 0 -or $Root.id -cne "graphcode-root" -or $Root.process -ne $ProcessId -or + [string]::IsNullOrWhiteSpace($Root.runtime) -or $Root.multiple -isnot [bool] -or $Root.multiple -or + -not (Test-UiaCanonicalNavigationId $ExpectedId)) { + throw "Navigation selection: invalid owned single-selection root or expected destination" + } + $ids = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $runtimes = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $null = $runtimes.Add($Root.runtime) + foreach ($row in $Rows) { + if (-not (Test-UiaCanonicalNavigationId $row.id) -or -not $ids.Add($row.id) -or + [string]::IsNullOrWhiteSpace($row.runtime) -or -not $runtimes.Add($row.runtime) -or + $row.process -ne $ProcessId -or $row.selected -isnot [bool] -or + $row.containerId -cne $Root.id -or $row.containerRuntime -cne $Root.runtime -or + $row.containerProcess -ne $ProcessId) { + throw "Navigation selection: duplicate, foreign, aliased or wrongly contained navigation row" + } + } + if (-not $ids.Contains("overview-destination") -or -not $ids.Contains("quick-chats-destination")) { + throw "Navigation selection: Graph or Quick Chats omitted SelectionItem" + } + $selectedRows = @($Rows | Where-Object selected) + if ($selectedRows.Count -ne 1 -or $selectedRows[0].id -cne $ExpectedId -or + $Selected.Count -ne 1 -or $Selected[0].id -cne $ExpectedId -or + $Selected[0].runtime -cne $selectedRows[0].runtime -or $Selected[0].process -ne $ProcessId) { + throw "Navigation selection: IsSelected and root GetSelection do not identify exactly the expected destination" + } +} + +function Assert-UiaSingleNavigationSelection($root, $walker, [string] $expectedId, [int] $processId) { + $selection = $root.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) + $selected = @() + for ($attempt = 0; $attempt -lt 60; $attempt++) { + $selected = @($selection.Current.GetSelection()) + if ($selected.Count -eq 1 -and $selected[0].Current.AutomationId -ceq $expectedId) { break } + Start-Sleep -Milliseconds 50 + } + $projects = Find-FragmentById $root "projects" $walker + $loops = Find-FragmentById $root "loops" $walker + Require ($null -ne $projects -and $null -ne $loops) "Navigation selection lost a sidebar fragment" + $elements = @( + @(Get-SidebarNavigationChildren $root $projects $walker) + + @(Get-DirectChildren $loops $walker) | + Where-Object { Test-UiaCanonicalNavigationId $_.Current.AutomationId } + ) + $rows = @($elements | ForEach-Object { + $item = $_.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $container = $item.Current.SelectionContainer + [pscustomobject]@{ + id = [string]$_.Current.AutomationId; runtime = $_.GetRuntimeId() -join ","; process = $_.Current.ProcessId + selected = $item.Current.IsSelected; containerId = [string]$container.Current.AutomationId + containerRuntime = $container.GetRuntimeId() -join ","; containerProcess = $container.Current.ProcessId + } + }) + $selectedSnapshot = @($selected | ForEach-Object { + [pscustomobject]@{ id = [string]$_.Current.AutomationId; runtime = $_.GetRuntimeId() -join ","; process = $_.Current.ProcessId } + }) + $rootSnapshot = [pscustomobject]@{ + id = [string]$root.Current.AutomationId; runtime = $root.GetRuntimeId() -join "," + process = $root.Current.ProcessId; multiple = $selection.Current.CanSelectMultiple + } + Assert-UiaNavigationSelectionSnapshot $rootSnapshot $rows $selectedSnapshot $processId $expectedId + Write-Host ("UIA_NAVIGATION_SELECTION_EVIDENCE=" + ([ordered]@{ + expected = $expectedId; container = $rootSnapshot; rows = $rows; selected = $selectedSnapshot + } | ConvertTo-Json -Compress -Depth 6)) +} + function Get-SidebarNavigationChildren( [System.Windows.Automation.AutomationElement] $root, [System.Windows.Automation.AutomationElement] $projects, @@ -6509,7 +6587,7 @@ try { Require ($openProjectRows.Count -eq 2) ` "sidebar scroll restoration did not preserve both open owner rows" Write-Host "UIA_SIDEBAR_SCROLL_RESTORED=overview-destination" - $null = $projects.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) + $null = $root.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) $null = $openProjectRows[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) $projectRowInvoke = $openProjectRows[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) $projectRowIds = @($openProjectRows | ForEach-Object { $_.Current.AutomationId }) @@ -6733,7 +6811,7 @@ try { "GraphCodeNativeForm") "$($nativeForm.Title) did not close after cancellation" } $loopIds = @($loopRows | ForEach-Object { $_.Current.AutomationId }) - $null = $loops.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) + $null = $root.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) foreach ($row in $loopRows) { $null = $row.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) $null = $row.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) @@ -7518,12 +7596,14 @@ try { $surfaceActionPatterns[$id] = $element.GetCurrentPattern( [System.Windows.Automation.InvokePattern]::Pattern) } - $surfaceActionPatterns["overview-destination"].Invoke() + $graphNavigation = Find-FragmentById $root "overview-destination" $rawWalker + $graphNavigation.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() $overviewProbe = Wait-ForGraphChildren $root $rawWalker ` { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' } ` { param($items) $items.Count -eq 2 } $graph = $overviewProbe.Graph $overviewCards = @($overviewProbe.Items) + Assert-UiaSingleNavigationSelection $root $rawWalker "overview-destination" $process.Id Require (($overviewCards.Count -eq 2) -and ((@($overviewCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) "Overview did not expose synchronized cards" @@ -7807,13 +7887,15 @@ try { Require ($restoredLegacyChips.Count -eq 0) ` "restored uninspected overview lane exposed a worktree chip" - $surfaceActionPatterns["quick-chats-destination"].Invoke() + $quickChatNavigation = Find-FragmentById $root "quick-chats-destination" $rawWalker + $quickChatNavigation.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() $quickChatProbe = Wait-ForGraphChildren $root $rawWalker ` { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA chat ' } ` { param($items) $items.Count -eq 2 } $graph = $quickChatProbe.Graph Require ($null -ne $graph) "missing graph fragment after switching to the Quick Chats destination" $quickChatCards = @($quickChatProbe.Items) + Assert-UiaSingleNavigationSelection $root $rawWalker "quick-chats-destination" $process.Id Require (($quickChatCards.Count -eq 2) -and ((@($quickChatCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA chat A|UIA chat B")) "Quick Chats did not expose synchronized cards: $(@($quickChatCards | ForEach-Object { $_.Current.Name }) -join '|')" $surfaceActionPatterns["canvas-primary-action"].Invoke() @@ -7895,6 +7977,7 @@ try { Require (($quickChatWorkspace.Current.BoundingRectangle.Width -gt 0) -and ($quickChatWorkspace.Current.BoundingRectangle.Height -gt 0)) ` "Quick Chat terminal workspace has empty bounds" + Assert-UiaSingleNavigationSelection $root $rawWalker $firstQuickRowId $process.Id $surfaceActionPatterns["zoom-in"].Invoke() $surfaceActionPatterns["actual-size"].Invoke() $surfaceActionPatterns["zoom-out"].Invoke() @@ -7917,8 +8000,10 @@ try { "sidebar rows were unavailable before dynamic invocation" $dynamicInvokedProjectRow = [string]$activeProjectRow.Current.AutomationId $dynamicInvokedLoopRow = [string]$activeLoopRow.Current.AutomationId - $activeProjectRow.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() - $activeLoopRow.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $activeProjectRow.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() + Assert-UiaSingleNavigationSelection $root $rawWalker $dynamicInvokedProjectRow $process.Id + $activeLoopRow.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() + Assert-UiaSingleNavigationSelection $root $rawWalker $dynamicInvokedLoopRow $process.Id Start-Sleep -Milliseconds 250 $process.Refresh() $dynamicInvocationHasExited = $process.HasExited diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 8dfe39e4..0633b106 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -30,6 +30,7 @@ The Windows port must have runnable commands before implementation fleets begin. | Native accessibility provider (MSVC and Windows SDK) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.ps1` | | Navigation selection without desktop/UIA entrypoints (intercepted C++ branch required) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.ps1 -HeadlessSelection` (179 checks; retains compiler/test scratch; separate from the default native 280-check suite) | | Navigation runner compile-plan controls (no compiler/GUI execution) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.Harness.Tests.ps1 -PlanOnly` (14 source/data checks; compiled `ui.navigation-runner-contracts`) | +| Shared navigation selection oracle (no UIA import or native calls) | `pwsh -NoProfile -File Tools\windows\Tests\NavigationSelectionOracle.Tests.ps1 -ArtifactRoot $ownedFreshRoot` (27 cases; compiled `ui.navigation-oracle-contracts`) | | Native accessibility harness failure attribution (mock tools, no native fixture) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.Harness.Tests.ps1` | | Native release feed, URL handoff, and allocation safety | `pwsh Tools\windows\Tests\WindowsShell.Tests.ps1 -ZigExecutable $env:GRAPHCODE_ZIG0152` (includes updater tests) | | Successful-update rollback cleanup, locked retry, and failed-recovery preservation | `pwsh Tools\windows\Tests\WindowsShell.Tests.ps1 -ZigExecutable $env:GRAPHCODE_ZIG0152` (includes `WindowsRollbackCleanup.zig`) | diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index b3c00ebe..65ab0ed3 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -672,7 +672,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| | Graph row | Pinned global graph row with graph glyph | The sidebar now keeps a dedicated Graph destination visible even with no open project, labels it with a graph identity glyph, and routes it through the existing global-overview hit target and UIA destination **Reopened (visual) 2026-10-13:** the side-by-side review against the macOS reference found letter glyph "G" instead of the icon; no selected pill. **Tracked in [#767](https://github.com/scgopi/GraphCode/issues/767) (implementation gap).** | Partial | -| Quick Chats group | Selectable header, hover New Chat, disclosure, child rows | The native header remains selectable, reveals a hover-only New Chat action and disclosure, and exposes stable selectable child rows with Rename/Delete context actions. Focused menu tests cover stable chat identity; the live UIA gate invokes New Chat, collapses and restores children, and verifies child runtime identity survives. **Reopened (visual) 2026-10-13:** the side-by-side review against the macOS reference found no selection highlight; "CHATS" heading instead of the macOS rows. **Tracked in [#767](https://github.com/scgopi/GraphCode/issues/767) (implementation gap).** | Partial | +| Quick Chats group | Selectable header, hover New Chat, disclosure, child rows | The native header remains selectable, reveals a hover-only New Chat action and disclosure, and exposes stable selectable child rows with Rename/Delete context actions. Focused menu tests cover stable chat identity; the live UIA gate invokes New Chat, collapses and restores children, and verifies child runtime identity survives. **Reopened (visual) 2026-10-13:** the side-by-side review against the macOS reference found no selection highlight; "CHATS" heading instead of the macOS rows. **Tracked in [#767](https://github.com/scgopi/GraphCode/issues/767) (implementation gap).** **Shared acceptance preparation for [#801](https://github.com/scgopi/GraphCode/issues/801), 2026-11-09:** the gate now requires Graph, Quick Chats, project, loop and active chat to share the root Selection container, agree on exactly one selected runtime identity, and expose SelectionItem. Needs-you and workspace/action aliases remain in the UI but are excluded from canonical navigation. Twenty-seven source/managed controls cover the observation predicate and actual read-only wrapper, including delayed publication, timeout, alias exclusion and foreign/stale identities without UIA imports. Product-owner intercepted C++ checks and paint/publication fixtures are separate evidence; no current-source native UIA run, real selection-event delivery or paired paint capture has been admitted. | Partial | | Local/remote sections | Group labels, independent collapse, folder/network glyphs | LOCAL and REMOTE retain local/folder and remote/network identity and now toggle independently as native section actions. Focused layout coverage validates mixed ordering, and the live UIA gate collapses LOCAL while proving the REMOTE row and its stable automation identity remain present before restoring LOCAL. **Reopened (visual) 2026-10-13:** the side-by-side review against the macOS reference found "GRAPH/Projects/CHATS" headings and letter glyphs instead of the "Local Projects" caption. **Tracked in [#767](https://github.com/scgopi/GraphCode/issues/767) (implementation gap).** | Partial | | Project rows | Selection, folder type, hover New Loop, disclosure | Open project rows retain selection and local/remote glyphs, reveal hover-only New Loop and disclosure controls, and collapse/restore their own loop tree without changing row identity. The live UIA gate invokes the project-row New Loop action into the real native node form and exercises project collapse/expand through stable UIA actions. **Reopened (visual) 2026-10-13:** the side-by-side review against the macOS reference found "v L" glyphs and about 40 px gaps instead of folder rows with + and a chevron. **Tracked in [#767](https://github.com/scgopi/GraphCode/issues/767) (implementation gap).** | Partial | | Nested loop tree | Edge-derived hierarchy, persisted expansion, drag reorder of roots | Handoff edges derive a cycle-safe root/descendant tree; nested rows disclose and collapse by stable node ID, expanded IDs persist atomically in the GraphCode support directory, and root rows now reorder through live pointer drag backed by the existing transactional `root` records. Focused Sidebar/Wire coverage and the deterministic UIA gate verify observable reorder plus emission of the new `sidebarNodesReordered` daemon command for server-side persistence parity. **Reopened (visual) 2026-10-13:** the side-by-side review against the macOS reference found no indentation; children hidden; goal and timed loops not visible. **Tracked in [#767](https://github.com/scgopi/GraphCode/issues/767) (implementation gap).** | Partial | From 9bdd2dbe4105b19eb435e5d83d58610e952c5fc8 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 9 Nov 2026 12:05:00 -0700 Subject: [PATCH 06/16] Bound native accessibility runner execution and retain original evidence Use the existing owned-child supervisor for discovery and compile/test under one clock, preserve raw streams and unknown leases, and verify 16 headless safety controls without executing a C++ fixture. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit 18fa59f93cf0d3c2534bd4d46a6a8974328f1871) --- Tools/windows/ShellOwnedProcess.cs | 4 + ...ityProvider.Native.Tests.Harness.Tests.ps1 | 246 ++++++++++++++++-- .../AccessibilityProvider.Native.Tests.ps1 | 194 +++++++++++--- .../ValidationRunner/Manifests/ui.json | 4 + .../ValidationRunner/Ui/UiAdapterCases.cs | 3 + Tools/windows/validation-matrix.md | 32 +++ 6 files changed, 415 insertions(+), 68 deletions(-) diff --git a/Tools/windows/ShellOwnedProcess.cs b/Tools/windows/ShellOwnedProcess.cs index 7158b9ac..2fb6a7bd 100644 --- a/Tools/windows/ShellOwnedProcess.cs +++ b/Tools/windows/ShellOwnedProcess.cs @@ -9,6 +9,10 @@ using System.Threading.Tasks; public sealed class ShellOwnedLaunch { + [DllImport("kernel32.dll")] + static extern uint GetOEMCP(); + public static int OemCodePage() { return checked((int)GetOEMCP()); } + public static string SelectWorkingDirectory(ProcessStartInfo info) { return String.IsNullOrEmpty(info.WorkingDirectory) ? null : info.WorkingDirectory; } diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 index ae9cd11a..d84077f5 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 @@ -3,7 +3,8 @@ param( [switch] $PlanOnly, [string] $RepositoryRoot, [string] $ArtifactRoot, - [string] $AssemblyPath + [string] $AssemblyPath, + [string] $NativeScriptUnderTest ) $ErrorActionPreference = "Stop" @@ -25,7 +26,7 @@ foreach ($headless in @($false, $true)) { @("/DGC_UIA_HEADLESS_SELECTION", "", "oleaut32.lib user32.lib uuid.lib", "Headless navigation selection: 179 checks, 0 failures") } else { - @("", '"%~3"', "oleaut32.lib uiautomationcore.lib user32.lib", + @("", '"%GRAPHCODE_NATIVE_PROVIDER%"', "oleaut32.lib uiautomationcore.lib user32.lib", "Native accessibility provider: 280 checks, 0 failures") } $actual = @($plan.definitions, $plan.providerSource, $plan.libraries, $plan.summary) @@ -58,9 +59,26 @@ if ($PlanOnly) { return $planChecks } +$repo = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +if (-not $AssemblyPath) { $AssemblyPath = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") } +& (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $AssemblyPath -RequiredType "GraphCode.Validation.Ownership.OwnedChildSupervisor" | Out-Null +foreach ($name in @("Invoke-AccessibilityProviderOwnedStep", "Get-AccessibilityProviderRemaining", + "Wait-AccessibilityProviderOwnershipRecovery", "Wait-AccessibilityProviderRetainedAuthority")) { + $found = @($nativeAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true)) + if ($found.Count -ne 1) { throw "Missing unique native supervision helper: $name" } + . ([scriptblock]::Create($found[0].Extent.Text)) +} +if (-not $NativeScriptUnderTest) { $NativeScriptUnderTest = $nativeScript } +$safetyCases = [Collections.Generic.List[string]]::new() $scratch = Join-Path ([IO.Path]::GetTempPath()) ("graphcode uia harness-" + [char]0xe9 + [guid]::NewGuid()) -function Test-AbruptStage([string] $Stage, [switch] $RepeatedPath) { - $fixture = Join-Path $scratch $(if ($RepeatedPath) { "$Stage-repeated-path" } else { $Stage }) +function Test-AbruptStage([string] $Stage, [switch] $RepeatedPath, [switch] $TimeoutMode, + [switch] $MetadataFailure, [switch] $FakeSummary) { + $caseName = if ($FakeSummary) { "fake-summary" } elseif ($MetadataFailure) { "timeout-metadata" } elseif ($TimeoutMode) { "timeout" } elseif ($RepeatedPath) { "$Stage-repeated-path" } else { $Stage } + $fixture = Join-Path $scratch $caseName $childTemp = Join-Path $fixture "temp" New-Item -ItemType Directory -Path $childTemp | Out-Null $vcvars = Join-Path $fixture "vcvars.cmd" @@ -74,16 +92,29 @@ function Test-AbruptStage([string] $Stage, [switch] $RepeatedPath) { [IO.File]::WriteAllText($vcvars, $setup, [Text.Encoding]::ASCII) if ($Stage -ne "vcvars") { $tool = if ($Stage -eq "where-cl") { "where.cmd" } else { "cl.cmd" } - [IO.File]::WriteAllText((Join-Path $fixture $tool), "@echo off`r`nexit 255`r`n", [Text.Encoding]::ASCII) + $toolBody = "@echo off`r`nexit 255`r`n" + if ($TimeoutMode) { + $extra = if ($MetadataFailure) { 'mkdir native\supervision.json' + "`r`n" } else { "" } + $toolBody = "@echo off`r`n$extra`"$(Join-Path $PSHOME 'pwsh.exe')`" -NoProfile -Command `"[Console]::WriteLine('timeout-ready'); [Console]::Error.WriteLine('timeout-stderr'); Start-Sleep -Seconds 30`"`r`n" + } + if ($FakeSummary) { $toolBody = "@echo off`r`necho Native accessibility provider: 280 checks, 0 failures`r`nexit /b 0`r`n" } + [IO.File]::WriteAllText((Join-Path $fixture $tool), $toolBody, [Text.Encoding]::ASCII) } $wrapper = Join-Path $fixture "invoke.ps1" [IO.File]::WriteAllText($wrapper, @' -param([string] $NativeScript, [string] $VcVars) +param([string] $NativeScript, [string] $VcVars, [int] $CaseTimeout, [switch] $LegacyControl) $before = $env:PATH $result = 0 try { - & $NativeScript -VcVars $VcVars + if ($LegacyControl) { & $NativeScript -VcVars $VcVars } + else { & $NativeScript -VcVars $VcVars -TimeoutMilliseconds $CaseTimeout } } catch { + for ($errorNode = $_.Exception; $errorNode; $errorNode = $errorNode.InnerException) { + if ($errorNode.Data.Contains("NativeSupervisionReportFailure")) { + Write-Host "NATIVE_REPORT_FAILURE_RETAINED=True" + break + } + } Write-Error -ErrorRecord $_ -ErrorAction Continue $result = 1 } finally { @@ -94,34 +125,77 @@ exit $result '@, [Text.Encoding]::ASCII) $start = [Diagnostics.ProcessStartInfo]::new((Get-Command pwsh -ErrorAction Stop).Source) $start.UseShellExecute = $false + $start.CreateNoWindow = $true $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true + [Text.Encoding]::RegisterProvider([Text.CodePagesEncodingProvider]::Instance) + $wrapperEncoding = [Text.Encoding]::GetEncoding([ShellOwnedLaunch]::OemCodePage(), + [Text.EncoderFallback]::ExceptionFallback, [Text.DecoderFallback]::ExceptionFallback) + $start.StandardOutputEncoding = $wrapperEncoding + $start.StandardErrorEncoding = $wrapperEncoding foreach ($argument in @( "-NoProfile", "-File", $wrapper, "-NativeScript", - (Join-Path $PSScriptRoot "AccessibilityProvider.Native.Tests.ps1"), - "-VcVars", $vcvars)) { + $NativeScriptUnderTest, "-VcVars", $vcvars, + "-CaseTimeout", $(if ($TimeoutMode) { "1000" } else { "10000" }))) { $start.ArgumentList.Add($argument) } + if ($NativeScriptUnderTest -cne $nativeScript) { $start.ArgumentList.Add("-LegacyControl") } $start.Environment["TEMP"] = $childTemp $start.Environment["TMP"] = $childTemp + $start.Environment["GRAPHCODE_VALIDATION_ASSEMBLY"] = $AssemblyPath if ($RepeatedPath) { $start.Environment["PATH"] = (@($fixture) + @($fixture.ToUpperInvariant()) * 99 + @("$env:SystemRoot\System32")) -join ";" } - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - try { - if (-not $process.Start()) { throw "Native harness child did not start" } - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit(30000)) { - $process.Kill($true) - $process.WaitForExit() - throw "Native harness launcher-only child timed out" + $execution = Invoke-AccessibilityProviderOwnedStep $start 30000 (Join-Path $fixture "outer-supervision") + $diagnosticPrefix = "NATIVE_UIA_DIAGNOSTIC_JSON=" + $output = (@(($execution.StandardOutput + $execution.StandardError) -split "\r?\n") | ForEach-Object { + if ($_.StartsWith($diagnosticPrefix, [StringComparison]::Ordinal)) { + $_.Substring($diagnosticPrefix.Length) | ConvertFrom-Json + } else { $_ } + }) -join "`n" + Write-Host ("NATIVE_HARNESS_CHILD_OUTPUT_JSON=" + ($output | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii)) + if ($execution.ExitCode -eq 0) { throw "RED: native harness masked $Stage failure" } + $retained = @(Get-ChildItem -LiteralPath $childTemp -Directory) + if ($retained.Count -ne 1) { throw "Native runner deleted or failed to retain its exact scratch after $caseName" } + $nativeEvidence = Join-Path $retained[0].FullName "native" + if ($FakeSummary) { + if ($output -notmatch "did not report exactly one compile/test execution marker" -or + $output -match "\[native-uia\] stage=test-exe start") { + throw "Fake positive summary without actual fixture execution was accepted" + } + $proof = Get-Content (Join-Path $nativeEvidence "supervision.json") -Raw | ConvertFrom-Json + if ($proof.exitCode -ne 0 -or -not $proof.cleanup.ResourcesClosed) { throw "Fake-summary control lost its real host result" } + $safetyCases.Add($caseName) + return + } + if ($TimeoutMode) { + if ($output -notmatch "OWNED_CAPTURE_EXIT: original deadline exceeded" -or + $output -match "\[native-uia\] stage=test-exe start") { + throw "Native timeout was masked or continued into the fixture" + } + $raw = @(Get-ChildItem -LiteralPath $nativeEvidence -Filter "*-stdout.log") + $rawError = @(Get-ChildItem -LiteralPath $nativeEvidence -Filter "*-stderr.log") + if ($raw.Count -ne 1 -or $rawError.Count -ne 1 -or + -not [IO.File]::ReadAllText($raw[0].FullName).Contains("timeout-ready") -or + -not [IO.File]::ReadAllText($rawError[0].FullName).Contains("timeout-stderr")) { + throw "Native timeout lost independent partial output" + } + if ($MetadataFailure) { + if (-not (Test-Path -LiteralPath (Join-Path $nativeEvidence "supervision.json") -PathType Container)) { + throw "Metadata failure control did not reach its actual collision" + } + if ($output -notmatch "NATIVE_REPORT_FAILURE_RETAINED=True") { throw "Metadata write failure replaced or lost its primary diagnostic" } + } else { + $proof = Get-Content (Join-Path $nativeEvidence "supervision.json") -Raw | ConvertFrom-Json + if (-not $proof.failed -or -not $proof.cleanup.RootExited -or -not $proof.cleanup.JobEmpty -or + -not $proof.cleanup.StdoutEof -or -not $proof.cleanup.StderrEof -or -not $proof.cleanup.ResourcesClosed) { + throw "Timed-out native host did not prove original job/root/pipe cleanup" + } + } + $safetyCases.Add($caseName) + return } - $output = $stdout.GetAwaiter().GetResult() + $stderr.GetAwaiter().GetResult() - Write-Host $output - if ($process.ExitCode -eq 0) { throw "RED: native harness masked $Stage failure" } if ($output -notmatch "\[native-uia\] stage=$Stage start" -or $output -notmatch '\[native-uia\] cmd exit=255') { throw "RED: native harness lost the actual $Stage command exit" @@ -133,8 +207,15 @@ exit $result ($Stage -ne "compile" -and $output -match '\[native-uia\] stage=compile start')) { throw "RED: native harness continued after abrupt $Stage failure" } - if (@(Get-ChildItem -LiteralPath $childTemp -Force).Count -ne 0) { - throw "RED: native harness left owned native scratch after $Stage failure" + $proof = Get-Content (Join-Path $nativeEvidence "supervision.json") -Raw | ConvertFrom-Json + if ($proof.exitCode -ne 255 -or -not $proof.cleanup.RootExited -or -not $proof.cleanup.JobEmpty -or + -not $proof.cleanup.StdoutEof -or -not $proof.cleanup.StderrEof -or -not $proof.cleanup.ResourcesClosed) { + throw "Native mock failure did not retain exact exit and successful held cleanup proof" + } + if ($proof.stdoutCodePage -ne [ShellOwnedLaunch]::OemCodePage() -or + $proof.stderrCodePage -ne $proof.stdoutCodePage) { throw "cmd producer encodings were not bound explicitly" } + if ($Stage -ceq "compile" -and -not $output.Contains((Join-Path $fixture "cl.cmd"))) { + throw "Controlled non-ASCII compiler path did not roundtrip through cmd's actual OEM codec" } if ($output -notmatch '\[native-harness\] original PATH restored') { throw "RED: native harness did not restore the exact original PATH after $Stage failure" @@ -148,9 +229,7 @@ exit $result throw "RED: native harness did not preserve case-insensitive first-occurrence PATH order" } } - } finally { - $process.Dispose() - } + $safetyCases.Add($caseName) } try { foreach ($stage in @("vcvars", "where-cl", "compile")) { @@ -158,6 +237,117 @@ try { } Test-AbruptStage "compile" -RepeatedPath Write-Host "Native accessibility harness: 25 checks, 0 failures (mock tools; no native fixture)" + Test-AbruptStage "compile" -FakeSummary + Test-AbruptStage "compile" -TimeoutMode + Test-AbruptStage "compile" -TimeoutMode -MetadataFailure + $clock = [Diagnostics.Stopwatch]::StartNew() + $first = Get-AccessibilityProviderRemaining $clock 1000 + [Threading.Thread]::Sleep(20) + $second = Get-AccessibilityProviderRemaining $clock 1000 + if ($first -le 0 -or $second -le 0 -or $second -ge $first) { throw "Original case budget was reset across steps" } + $safetyCases.Add("shared-budget") + $refused = $false + try { $null = Get-AccessibilityProviderRemaining $clock 1 } catch [TimeoutException] { $refused = $true } + if (-not $refused) { throw "Expired original budget did not refuse another launch" } + $safetyCases.Add("expired-budget") + $info = [Diagnostics.ProcessStartInfo]::new((Get-Command pwsh).Source) + $info.UseShellExecute = $false + $refused = $false + try { $null = Invoke-AccessibilityProviderOwnedStep $info 0 (Join-Path $scratch "must-not-create") } catch [TimeoutException] { $refused = $true } + if (-not $refused -or (Test-Path -LiteralPath (Join-Path $scratch "must-not-create"))) { throw "Zero-budget step acquired resources" } + $safetyCases.Add("zero-budget") + & { + $state = @{ waits = 0; signals = 0 } + function Wait-AccessibilityProviderOwnershipRecovery { $state.waits++; throw "controlled retained authority" } + function Write-Host($Object) { + if ([string]$Object -cne "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven") { throw "Unexpected retention signal" } + $state.signals++ + } + Wait-AccessibilityProviderRetainedAuthority 0 + if ($state.waits -ne 0 -or $state.signals -ne 0) { throw "Zero retained leases entered the passive barrier" } + $safetyCases.Add("zero-retention") + $failure = $null + try { Wait-AccessibilityProviderRetainedAuthority 1 } catch { $failure = $_ } + if (-not $failure -or $failure.Exception.Message -cne "controlled retained authority" -or + $state.waits -ne 1 -or $state.signals -ne 1) { throw "UNKNOWN did not signal and enter passive original-authority retention" } + $safetyCases.Add("unknown-retention") + $state.waits = 0 + function Write-Host($Object) { throw "controlled diagnostic failure" } + try { Wait-AccessibilityProviderRetainedAuthority 1 } catch { $failure = $_ } + if ($state.waits -ne 1) { throw "Diagnostic failure bypassed passive original-authority retention" } + $safetyCases.Add("unknown-diagnostic-failure") + } + $invalidInfo = [Diagnostics.ProcessStartInfo]::new((Get-Command pwsh).Source) + $invalidInfo.UseShellExecute = $false + $invalidInfo.CreateNoWindow = $true + $invalidInfo.RedirectStandardOutput = $true + $invalidInfo.RedirectStandardError = $true + $invalidInfo.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + $invalidInfo.StandardErrorEncoding = [Text.UTF8Encoding]::new($false, $true) + $invalidScript = '[Console]::OpenStandardOutput().Write([byte[]]@(255),0,1)' + foreach ($argument in @("-NoProfile", "-EncodedCommand", [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($invalidScript)))) { + $invalidInfo.ArgumentList.Add($argument) + } + $invalidRoot = Join-Path $scratch "invalid-utf8" + $failure = $null + try { $null = Invoke-AccessibilityProviderOwnedStep $invalidInfo 10000 $invalidRoot } catch { $failure = $_ } + $raw = @(Get-ChildItem -LiteralPath $invalidRoot -Filter "*-stdout.log") + $proof = Get-Content (Join-Path $invalidRoot "supervision.json") -Raw | ConvertFrom-Json + if (-not $failure -or $failure.Exception.Message -notmatch "OWNED_CAPTURE_DRAIN" -or $raw.Count -ne 1 -or + ([IO.File]::ReadAllBytes($raw[0].FullName) -join ",") -cne "255" -or + $proof.stdoutCodePage -ne 65001 -or -not $proof.failed -or -not $proof.cleanup.ResourcesClosed) { + throw "Invalid-byte control was recoded, silently replaced or lost authoritative raw evidence" + } + $safetyCases.Add("invalid-byte-refusal") + $missing = [Diagnostics.ProcessStartInfo]::new((Join-Path $scratch "missing.exe")) + $missing.UseShellExecute = $false + $failure = $null + $missingRoot = Join-Path $scratch "missing-executable" + try { $null = Invoke-AccessibilityProviderOwnedStep $missing 1000 $missingRoot } catch { $failure = $_ } + $proof = Get-Content (Join-Path $missingRoot "supervision.json") -Raw | ConvertFrom-Json + if (-not $failure -or -not $proof.failed -or $null -ne $proof.cleanup -or $proof.retainedLeaseCount -ne 0) { + throw "Missing executable did not preserve explicit unavailable cleanup evidence without inventing success" + } + $safetyCases.Add("missing-executable") + if ([regex]::Matches($nativeSource, [regex]::Escape('(Get-AccessibilityProviderRemaining $caseClock $TimeoutMilliseconds)')).Count -ne 2) { + throw "Discovery and compile/test must use the same original clock/budget" + } + $pairClock = [Diagnostics.Stopwatch]::StartNew() + $pairInfo = [Diagnostics.ProcessStartInfo]::new((Get-Command pwsh).Source) + $pairInfo.UseShellExecute = $false + $pairInfo.CreateNoWindow = $true + $pairInfo.RedirectStandardOutput = $true + $pairInfo.RedirectStandardError = $true + foreach ($argument in @("-NoProfile", "-Command", "[Console]::WriteLine('stage-one')")) { $pairInfo.ArgumentList.Add($argument) } + $firstStep = Invoke-AccessibilityProviderOwnedStep $pairInfo (Get-AccessibilityProviderRemaining $pairClock 2000) ` + (Join-Path $scratch "pair-first") + if ($firstStep.ExitCode -ne 0 -or -not $firstStep.StandardOutput.Contains("stage-one")) { throw "First original-budget step did not execute" } + $pairInfo.ArgumentList.Clear() + foreach ($argument in @("-NoProfile", "-Command", "Start-Sleep -Seconds 10")) { $pairInfo.ArgumentList.Add($argument) } + $failure = $null + $pairSecond = Join-Path $scratch "pair-second" + try { $null = Invoke-AccessibilityProviderOwnedStep $pairInfo (Get-AccessibilityProviderRemaining $pairClock 2000) $pairSecond } + catch { $failure = $_ } + $proof = Get-Content (Join-Path $pairSecond "supervision.json") -Raw | ConvertFrom-Json + if (-not $failure -or $failure.Exception.Message -notmatch "OWNED_CAPTURE_EXIT" -or + $proof.timeoutMilliseconds -ge 2000 -or $proof.launchBudgetMilliseconds -gt $proof.timeoutMilliseconds -or + -not $proof.failed -or -not $proof.cleanup.ResourcesClosed) { + throw "Second actual supervised step refreshed its original total budget or lost cleanup proof" + } + $safetyCases.Add("two-step-original-budget") + if ($safetyCases.Count -ne 16 -or @($safetyCases | Sort-Object -Unique).Count -ne 16) { throw "Native safety case inventory differs" } + Write-Host "Native accessibility runner safety: 16 cases, 0 failures" + if ($ArtifactRoot) { + Write-Output ("UI_CASE_RESULT=" + ([ordered]@{ + id = "ui.navigation.native-runner-safety"; status = "Passed"; executedCount = 16 + groups = $safetyCases.ToArray() + evidence = "Actual existing runner/import/supervised invocation; controlled failing and timed-out tools; no C++ fixture or UIA entrypoints" + } | ConvertTo-Json -Compress)) + } } finally { - Remove-Item -LiteralPath $scratch -Recurse -Force + try { Write-Host ("NATIVE_HARNESS_RETAINED_SCRATCH_JSON=" + ($scratch | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii)) } + finally { + Wait-AccessibilityProviderRetainedAuthority ` + ([GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount) + } } diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 index 1ea5a3af..8e099a80 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.ps1 @@ -1,7 +1,84 @@ [CmdletBinding()] -param([string] $VcVars, [switch] $HeadlessSelection) +param( + [string] $VcVars, + [switch] $HeadlessSelection, + [ValidateRange(1, 600000)][int] $TimeoutMilliseconds = 120000 +) $ErrorActionPreference = "Stop" +$caseClock = [Diagnostics.Stopwatch]::StartNew() +function Write-AccessibilityProviderDiagnostic([string] $Line) { + if ($Line -cmatch '[^\x00-\x7f]') { + Write-Host ("NATIVE_UIA_DIAGNOSTIC_JSON=" + ($Line | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii)) + } else { Write-Host $Line } +} +function Get-AccessibilityProviderRemaining([Diagnostics.Stopwatch] $Clock, [int] $Budget) { + $remaining = $Budget - [int]$Clock.ElapsedMilliseconds + if ($remaining -le 0) { throw [TimeoutException]::new("Native accessibility provider original case deadline exceeded") } + return $remaining +} + +function Invoke-AccessibilityProviderOwnedStep( + [Diagnostics.ProcessStartInfo] $StartInfo, + [int] $TimeoutMilliseconds, + [string] $ArtifactRoot +) { + $stepClock = [Diagnostics.Stopwatch]::StartNew() + if ($TimeoutMilliseconds -le 0) { throw [TimeoutException]::new("Native accessibility provider has no launch budget") } + $null = New-Item -ItemType Directory -Path $ArtifactRoot + $result = $null + $failure = $null + $launchBudget = $null + try { + $launchBudget = $TimeoutMilliseconds - [int]$stepClock.ElapsedMilliseconds + if ($launchBudget -le 0) { throw [TimeoutException]::new("Native accessibility provider step exhausted its original budget before launch") } + $result = [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RunAsync( + $StartInfo, [TimeSpan]::FromMilliseconds($launchBudget), $ArtifactRoot, + [Threading.CancellationToken]::None).GetAwaiter().GetResult() + } catch { + $failure = $_ + throw + } finally { + $proof = if ($result) { $result.Cleanup } else { $null } + if ($failure) { + for ($exception = $failure.Exception; $exception; $exception = $exception.InnerException) { + if ($exception.Data.Contains("OwnedCleanupProof")) { $proof = $exception.Data["OwnedCleanupProof"]; break } + } + } + $record = [ordered]@{ + stage = Split-Path -Leaf $ArtifactRoot + executable = $StartInfo.FileName + timeoutMilliseconds = $TimeoutMilliseconds + launchBudgetMilliseconds = $launchBudget + exitCode = $(if ($result) { $result.ExitCode } else { $null }) + failed = $null -ne $failure + failureType = $(if ($failure) { $failure.Exception.GetBaseException().GetType().FullName } else { $null }) + stdoutCodePage = $(if ($StartInfo.StandardOutputEncoding) { $StartInfo.StandardOutputEncoding.CodePage } else { $null }) + stderrCodePage = $(if ($StartInfo.StandardErrorEncoding) { $StartInfo.StandardErrorEncoding.CodePage } else { $null }) + cleanup = $proof + retainedLeaseCount = [GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount + } + try { + [IO.File]::WriteAllText((Join-Path $ArtifactRoot "supervision.json"), + ($record | ConvertTo-Json -Depth 6), [Text.UTF8Encoding]::new($false)) + } catch { + if ($failure) { $failure.Exception.Data["NativeSupervisionReportFailure"] = $_.Exception } + else { throw } + } + } + return $result +} + +function Wait-AccessibilityProviderOwnershipRecovery { + [Threading.Thread]::Sleep([Threading.Timeout]::Infinite) +} + +function Wait-AccessibilityProviderRetainedAuthority([int] $RetainedLeaseCount) { + if ($RetainedLeaseCount -eq 0) { return } + try { Write-Host "GRAPHCODE_VALIDATION_ROOT_RETENTION_REQUIRED=ownership_unproven" } + finally { Wait-AccessibilityProviderOwnershipRecovery } +} + function Get-AccessibilityProviderNativeTestPlan([bool] $HeadlessSelection) { if ($HeadlessSelection) { return [pscustomobject]@{ @@ -13,7 +90,7 @@ function Get-AccessibilityProviderNativeTestPlan([bool] $HeadlessSelection) { } return [pscustomobject]@{ definitions = "" - providerSource = '"%~3"' + providerSource = '"%GRAPHCODE_NATIVE_PROVIDER%"' libraries = "oleaut32.lib uiautomationcore.lib user32.lib" summary = "Native accessibility provider: 280 checks, 0 failures" } @@ -28,24 +105,47 @@ if ($HeadlessSelection) { throw "Headless selection requires the intercepted C++ test branch; the legacy native entry will not run" } } -if (-not $VcVars) { - $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" - if (-not (Test-Path -LiteralPath $vswhere)) { throw "Visual Studio vswhere is required" } - $installation = & $vswhere -latest -products '*' ` - -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath - if ($LASTEXITCODE -ne 0 -or -not $installation) { throw "Visual C++ build tools are required" } - $VcVars = Join-Path $installation "VC\Auxiliary\Build\vcvars64.bat" -} -if (-not (Test-Path -LiteralPath $VcVars)) { throw "Missing Visual C++ setup: $VcVars" } - $scratch = Join-Path ([IO.Path]::GetTempPath()) ("graphcode-uia-native-" + [guid]::NewGuid()) $savedTemp = $env:TEMP $savedTmp = $env:TMP $savedPath = $env:PATH New-Item -ItemType Directory -Path $scratch | Out-Null +$bindingsLoaded = $false try { + $assemblyPath = $env:GRAPHCODE_VALIDATION_ASSEMBLY + if (-not $assemblyPath) { + $assemblyPath = & (Join-Path $PSScriptRoot "..\Build-CompiledValidationRunner.ps1") + } + & (Join-Path $PSScriptRoot "..\Import-CompiledValidationAssembly.ps1") ` + -AssemblyPath $assemblyPath -RequiredType "GraphCode.Validation.Ownership.OwnedChildSupervisor" | Out-Null + $bindingsLoaded = $true $env:TEMP = $scratch $env:TMP = $scratch + if (-not $VcVars) { + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path -LiteralPath $vswhere)) { throw "Visual Studio vswhere is required" } + $discovery = [Diagnostics.ProcessStartInfo]::new($vswhere) + $discovery.UseShellExecute = $false + $discovery.CreateNoWindow = $true + $discovery.WorkingDirectory = $scratch + $discovery.RedirectStandardOutput = $true + $discovery.RedirectStandardError = $true + $discovery.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + $discovery.StandardErrorEncoding = [Text.UTF8Encoding]::new($false, $true) + foreach ($argument in @("-latest", "-products", "*", "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath", "-utf8")) { + $discovery.ArgumentList.Add($argument) + } + $discovered = Invoke-AccessibilityProviderOwnedStep $discovery ` + (Get-AccessibilityProviderRemaining $caseClock $TimeoutMilliseconds) (Join-Path $scratch "discovery") + $installations = @($discovered.StandardOutput -split "\r?\n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($discovered.ExitCode -ne 0 -or $installations.Count -ne 1) { throw "Visual C++ build tools discovery did not return one installation" } + $VcVars = Join-Path $installations[0] "VC\Auxiliary\Build\vcvars64.bat" + } + if (-not (Test-Path -LiteralPath $VcVars)) { throw "Missing Visual C++ setup: $VcVars" } + foreach ($path in @($VcVars, $test, (Join-Path $repoRoot "graphcode-windows\src\AccessibilityProvider.cpp"))) { + if ($path -match '["%!\r\n]') { throw "Native accessibility compiler paths must be batch-safe" } + } $installer = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer" $pathEntries = @($savedPath -split ";") if (Test-Path -LiteralPath $installer) { $pathEntries = @($installer) + $pathEntries } @@ -55,7 +155,7 @@ try { if ($seenPaths.Add($entry)) { $entry } }) $env:PATH = $uniquePaths -join ";" - Write-Host "[native-uia] PATH entries=$($pathEntries.Count) -> $($uniquePaths.Count); length=$($savedPath.Length) -> $($env:PATH.Length)" + Write-AccessibilityProviderDiagnostic "[native-uia] PATH entries=$($pathEntries.Count) -> $($uniquePaths.Count); length=$($savedPath.Length) -> $($env:PATH.Length)" if ($env:PATH.Length -ge 8191) { throw "Native accessibility provider deduplicated PATH is still too long for cmd: $($env:PATH.Length)" } @@ -65,14 +165,14 @@ try { # vcvars can exit non-zero when an optional extension (for example the Linux # ConnectionManager) is absent even though the MSVC environment is usable, so # require a resolvable cl instead of trusting its exit code. - Write-Host "[native-uia] vcvars path: $VcVars" + Write-AccessibilityProviderDiagnostic "[native-uia] vcvars path: $VcVars" # Keep batch source ASCII; pass Unicode paths through cmd arguments. $command = @' @echo off cd /d "%~dp0" if errorlevel 1 exit /b %errorlevel% echo [native-uia] stage=vcvars start -call "%~1" +call "%GRAPHCODE_NATIVE_VCVARS%" set "vcvars_exit=%errorlevel%" echo [native-uia] stage=vcvars exit=%vcvars_exit% "%ComSpec%" /d /u /c set PATH >vcvars-path.txt @@ -82,7 +182,7 @@ set "where_exit=%errorlevel%" echo [native-uia] stage=where-cl exit=%where_exit% if not "%where_exit%"=="0" exit /b %where_exit% echo [native-uia] stage=compile start -cl /nologo /EHsc /std:c++17 TEST_DEFINITIONS /Fe:uia-native.exe "%~2" PROVIDER_SOURCE TEST_LIBRARIES +cl /nologo /EHsc /std:c++17 TEST_DEFINITIONS /Fe:uia-native.exe "%GRAPHCODE_NATIVE_TEST%" PROVIDER_SOURCE TEST_LIBRARIES set "compile_exit=%errorlevel%" echo [native-uia] stage=compile exit=%compile_exit% if not "%compile_exit%"=="0" exit /b %compile_exit% @@ -98,25 +198,46 @@ exit /b %test_exit% $commandPath = Join-Path $scratch "run-native.cmd" [IO.File]::WriteAllText($commandPath, $command.Replace("`r`n", "`n").Replace("`n", "`r`n"), [Text.Encoding]::ASCII) - $arguments = 'call "{0}" "{1}" "{2}" "{3}"' -f $commandPath, $VcVars, $test, $provider + $native = [Diagnostics.ProcessStartInfo]::new($env:ComSpec) + $native.UseShellExecute = $false + $native.CreateNoWindow = $true + $native.WorkingDirectory = $scratch + $native.RedirectStandardOutput = $true + $native.RedirectStandardError = $true + [Text.Encoding]::RegisterProvider([Text.CodePagesEncodingProvider]::Instance) + $oemEncoding = [Text.Encoding]::GetEncoding([ShellOwnedLaunch]::OemCodePage(), + [Text.EncoderFallback]::ExceptionFallback, [Text.DecoderFallback]::ExceptionFallback) + $native.StandardOutputEncoding = $oemEncoding + $native.StandardErrorEncoding = $oemEncoding + foreach ($argument in @("/d", "/s", "/c", "run-native.cmd")) { $native.ArgumentList.Add($argument) } + $native.Environment["GRAPHCODE_NATIVE_VCVARS"] = [IO.Path]::GetFullPath($VcVars) + $native.Environment["GRAPHCODE_NATIVE_TEST"] = $test + $native.Environment["GRAPHCODE_NATIVE_PROVIDER"] = $provider $nativeStage = "cmd-startup" - $nativeOutput = @(& $env:ComSpec /d /s /c $arguments 2>&1 | ForEach-Object { - $line = "$_" + $nativeResult = Invoke-AccessibilityProviderOwnedStep $native ` + (Get-AccessibilityProviderRemaining $caseClock $TimeoutMilliseconds) (Join-Path $scratch "native") + $nativeOutput = @(@($nativeResult.StandardOutput -split "\r?\n") + @($nativeResult.StandardError -split "\r?\n")) + foreach ($line in $nativeOutput) { if ($line -match '^\[native-uia\] stage=([a-z-]+) start$') { $nativeStage = $Matches[1] } - Write-Host $line - $line - }) - $nativeExit = $LASTEXITCODE - Write-Host "[native-uia] cmd exit=$nativeExit" + Write-AccessibilityProviderDiagnostic $line + } + $nativeExit = $nativeResult.ExitCode + Write-AccessibilityProviderDiagnostic "[native-uia] cmd exit=$nativeExit" if ($nativeExit -ne 0) { throw "Native accessibility provider stage '$nativeStage' failed: $nativeExit" } $setupPath = @(Get-Content -LiteralPath (Join-Path $scratch "vcvars-path.txt") -Encoding Unicode | Where-Object { $_ -match '^PATH=' }) if ($setupPath.Count -ne 1) { throw "Native accessibility provider did not record one post-vcvars PATH" } $setupPathLength = $setupPath[0].Substring(5).Length - Write-Host "[native-uia] post-vcvars PATH length=$setupPathLength (cmd limit=8191)" + Write-AccessibilityProviderDiagnostic "[native-uia] post-vcvars PATH length=$setupPathLength (cmd limit=8191)" if ($setupPathLength -ge 8191) { throw "Native accessibility provider post-vcvars PATH exceeds cmd limit: $setupPathLength" } + foreach ($marker in @("[native-uia] stage=compile exit=0", "[native-uia] stage=test-exe start", + "[native-uia] stage=test-exe exit=0")) { + if (@($nativeOutput | Where-Object { $_ -ceq $marker }).Count -ne 1) { + throw "Native accessibility provider did not report exactly one compile/test execution marker: $marker" + } + } if (@($nativeOutput | Where-Object { $_ -ceq $plan.summary }).Count -ne 1) { throw "Native accessibility provider test-exe did not report exactly one expected summary: $($plan.summary)" } @@ -124,20 +245,13 @@ exit /b %test_exit% $env:TEMP = $savedTemp $env:TMP = $savedTmp $env:PATH = $savedPath - if ($HeadlessSelection) { - Write-Host "HEADLESS_SELECTION_RETAINED_SCRATCH=$scratch" - } else { - # Native compiler outputs can stay locked briefly after the process exits. - for ($attempt = 1; Test-Path -LiteralPath $scratch; $attempt++) { - try { - Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction Stop - } catch { - if ($attempt -ge 10) { - Write-Warning "Native accessibility test scratch was not removed: $scratch ($($_.Exception.Message))" - break - } - Start-Sleep -Milliseconds 500 - } - } + try { + Write-AccessibilityProviderDiagnostic "NATIVE_ACCESSIBILITY_RETAINED_SCRATCH=$scratch" + if ($HeadlessSelection) { Write-AccessibilityProviderDiagnostic "HEADLESS_SELECTION_RETAINED_SCRATCH=$scratch" } + } finally { + if ($bindingsLoaded) { + Wait-AccessibilityProviderRetainedAuthority ` + ([GraphCode.Validation.Ownership.OwnedChildSupervisor]::RetainedLeaseCount) } + } } diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index fa5e344d..c274e91b 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -39,6 +39,10 @@ { "id": "ui.cards-pilot.zip-import-valid", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-flat-import-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-stale-identity-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, + { "id": "ui.navigation.native-runner-safety", "owner": "Ui", "minimumCount": 16, "exactCount": 16, + "requiredGroups": ["vcvars", "where-cl", "compile", "compile-repeated-path", "timeout", "timeout-metadata", + "shared-budget", "expired-budget", "zero-budget", "zero-retention", "unknown-retention", + "unknown-diagnostic-failure", "invalid-byte-refusal", "missing-executable", "two-step-original-budget", "fake-summary"] }, { "id": "ui.cards-pilot.zip-custody-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-spawn-scope-refused", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, { "id": "ui.cards-pilot.zip-quiescence-higher-total", "owner": "Ui", "minimumCount": 1, "requiredGroups": [] }, diff --git a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs index 9391e2e7..9433148a 100644 --- a/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs +++ b/Tools/windows/ValidationRunner/Ui/UiAdapterCases.cs @@ -270,6 +270,9 @@ internal static class UiAdapterCases }; internal static IReadOnlyList CreateSuites() => new IValidationSuite[] { + new StandaloneSuites.CaseScriptSuite("ui.native-runner-safety", + @"Tools\windows\Tests\AccessibilityProvider.Native.Tests.Harness.Tests.ps1", + "UI_CASE_RESULT=", new[] { "ui.navigation.native-runner-safety" }), new StandaloneSuites.CaseScriptSuite("ui.powershell-contracts", @"Tools\windows\ValidationRunner\Ui\UiPowerShellContracts.ps1", "UI_CASE_RESULT=", PowerShellIds), new StandaloneSuites.CaseScriptSuite("ui.delete-contracts", @"Tools\windows\ValidationRunner\Ui\UiDeleteContracts.ps1", "UI_CASE_RESULT=", DeleteIds), new StandaloneSuites.CaseScriptSuite("ui.root-source-contracts", @"Tools\windows\ValidationRunner\Ui\UiRootSourceContracts.ps1", "UI_CASE_RESULT=", RootSourceIds), diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 0633b106..ceb1ee6a 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -79,6 +79,38 @@ assert one owned-file receipt and its preserved hash, and emit distinct `UNTRACKED_OWNED_FILE_PREFLIGHT_KIND` markers. Require both markers as well as a positive test count when interpreting that coverage. +### Native accessibility runner admission + +The existing native entry now uses the compiled runner's +`OwnedChildSupervisor.RunAsync` for discovery and the compile/test command. +Both consume the same original `-TimeoutMilliseconds` clock (default 120000), +with the supervisor's separate five-second bounded cleanup reserve. Targets +start suspended under the original held handle/non-breakaway job; raw stdout +and stderr, cleanup proof, timeout and codec context stay in fresh scratch. +The runner never deletes that scratch, and UNKNOWN stops further work in a +passive original-authority hold rather than closing or retrying the lease. +A passive hold is not permission for an unbounded workload. A caller that +later removes its enclosing validation directory can still remove successful +evidence; runtime admission must separately account for that existing outer +cleanup policy and preserve protected outputs. + +The headless harness has 16 distinct safety cases, including actual runner +invocation with controlled abrupt/failing/timeout tools, partial stdout/stderr, +metadata-report failure, two actual steps sharing one clock, a missing executable, +invalid-byte refusal and a fake success-summary refusal. No real compiler or C++ +fixture executes in that harness. Its existing 25 failure-attribution checks and 14 compile-plan checks +remain separate. The compiled safety suite is `ui.native-runner-safety`. + +The console-free `cmd` channel uses the observed Windows OEM code page; +`vswhere -utf8` uses strict UTF-8. Raw bytes remain authoritative. Non-ASCII +display diagnostics use explicitly labeled JSON escaping without changing +console/OS code pages or rewriting the raw logs. The controlled non-ASCII path +roundtrip does not prove that every compiler/descendant emits that codec or +qualify all Unicode paths/MSVC diagnostics. The original UTF-8/OEM decode failure +and subsequent nested-host decode failure remain failed evidence, distinct from +the corrected passing run. Default native 280-check execution still requires +specific runtime approval; the headless switch is not a desktop grant. + ### Stable canvas card context menu The stable canvas card context menu mock contracts execute 16 managed From a5e70e6f4d4aa679caf9224fbb4c9ae4aca60450 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 09:39:14 -0700 Subject: [PATCH 07/16] Require real selection events for canonical sidebar navigation Bind five non-idempotent navigation Select transitions to exact owned event senders and single-selection readback; retain separate headless receipt controls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit e4ab18dd1c63a4c6c24de5594076103548f1c8d7) --- .../Tests/NavigationSelectionOracle.Tests.ps1 | 54 ++++++++++++++- .../ValidationRunner/Manifests/ui.json | 2 + Tools/windows/ValidationRunner/README.md | 12 ++++ .../Ui/Production/GraphCodeUiaGateState.cs | 2 + .../ValidationRunner/Ui/SuiteRegistration.cs | 2 +- Tools/windows/uia-live-gate.ps1 | 66 +++++++++++++++++++ 6 files changed, 135 insertions(+), 3 deletions(-) diff --git a/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 b/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 index e2f5ad62..9fb38f73 100644 --- a/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 +++ b/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 @@ -81,7 +81,7 @@ $calls = @($ast.FindAll({ $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq "Assert-UiaSingleNavigationSelection" }, $true)) -if ($calls.Count -ne 5) { throw "Live gate must check Graph, Quick Chats, project, loop and active chat selection" } +if ($calls.Count -ne 6) { throw "Live gate must check five navigation destinations and each event transition readback" } $derivedAst = [Management.Automation.Language.Parser]::ParseFile( (Join-Path $RepositoryRoot "Tools\windows\ValidationRunner\Ui\UiDerivedContracts.ps1"), [ref]$null, [ref]$errors) @@ -173,6 +173,51 @@ foreach ($case in @("live-binding", "live-alias-exclusion", "live-delayed-select $results.Add([pscustomobject]@{ name = $case; passed = $passed; error = $(if (-not $passed) { [string]$failure } else { "" }) }) } $failed = @($results | Where-Object { -not $_.passed }) +$eventResults = [Collections.Generic.List[object]]::new() +$eventDefinition = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Assert-UiaNavigationSelectionEventReceipt" +}, $true)) +if ($eventDefinition.Count -ne 1) { throw "Missing unique navigation event receipt oracle" } +. ([scriptblock]::Create($eventDefinition[0].Extent.Text)) +foreach ($case in @("graph", "quick-chats", "project", "loop", "chat", + "zero", "duplicate", "wrong-id", "wrong-runtime", "foreign", "alias", "empty-runtime")) { + $index = switch ($case) { "quick-chats" { 1 } "project" { 2 } "loop" { 3 } "chat" { 4 } default { 0 } } + $expected = $ids[$index] + $receipt = [pscustomobject]@{ count = 1; id = $expected; runtime = "42,1"; process = 42 } + switch ($case) { + "zero" { $receipt.count = 0 } + "duplicate" { $receipt.count = 2 } + "wrong-id" { $receipt.id = "loop-row-99" } + "wrong-runtime" { $receipt.runtime = "42,99" } + "foreign" { $receipt.process = 99 } + "alias" { $receipt.id = "needs-you-row-1"; $expected = $receipt.id } + "empty-runtime" { $receipt.runtime = "" } + } + $failure = $null + try { Assert-UiaNavigationSelectionEventReceipt $receipt $expected "42,1" 42 } catch { $failure = $_ } + $passed = ($null -eq $failure) -eq ($case -cin @("graph", "quick-chats", "project", "loop", "chat")) + if ($failure -and $failure.Exception.Message -notlike "Navigation event:*") { $passed = $false } + $eventResults.Add([pscustomobject]@{ name = $case; passed = $passed }) +} +$bindingSource = [IO.File]::ReadAllText((Join-Path $RepositoryRoot "Tools\windows\ValidationRunner\Ui\Production\GraphCodeUiaGateState.cs")) +if ($bindingSource -notmatch '(?s)private static void HandleSelected\(.*?SelectionSourceProcessId = element.Current.ProcessId;.*?SelectedEvents\+\+;') { + throw "Navigation event process receipt must come from the actual UIA event sender before count publication" +} +$eventWrappers = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq "Assert-UiaNavigationSelectionEvents" +}, $true)) +if ($eventWrappers.Count -ne 1 -or + $eventWrappers[0].Extent.Text -notmatch 'AddAutomationEventHandler' -or + $eventWrappers[0].Extent.Text -notmatch 'RemoveAutomationEventHandler' -or + $eventWrappers[0].Extent.Text -notmatch 'Assert-UiaSingleNavigationSelection' -or + $gateSource -notmatch 'Assert-UiaNavigationSelectionEvents \$root \$rawWalker @\("overview-destination", "quick-chats-destination", \$dynamicInvokedProjectRow, \$dynamicInvokedLoopRow, \$firstQuickRowId\)') { + throw "Live navigation event subscription/readback must cover all five canonical destinations" +} +$eventComplete = $eventResults.Count -eq 12 -and @($eventResults | Where-Object { -not $_.passed }).Count -eq 0 $complete = $results.Count -eq 27 -and @($results.name | Sort-Object -Unique).Count -eq 27 -and $failed.Count -eq 0 $null = New-Item -ItemType Directory -Force -Path $ArtifactRoot $resultPath = Join-Path $ArtifactRoot ("navigation-oracle-" + [guid]::NewGuid().ToString("N") + ".json") @@ -184,4 +229,9 @@ Write-Output ("UI_CASE_RESULT=" + ([ordered]@{ executedCount = $results.Count; groups = @($results | Where-Object passed | ForEach-Object name) evidence = "Actual shared navigation snapshot oracle; source-derived in-memory observations; no UIA import or native calls" } | ConvertTo-Json -Compress)) -if (-not $complete) { exit 1 } +Write-Output ("UI_CASE_RESULT=" + ([ordered]@{ + id = "ui.navigation.selection-event-controls"; status = $(if ($eventComplete) { "Passed" } else { "Failed" }) + executedCount = $eventResults.Count; groups = @($eventResults | Where-Object passed | ForEach-Object name) + evidence = "Actual navigation event receipt predicate and source-bound real handler wiring; nativeCalls=0" +} | ConvertTo-Json -Compress)) +if (-not $complete -or -not $eventComplete) { exit 1 } diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index 8c645c0c..b05ea102 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -1,6 +1,8 @@ { "units": [ { "id": "ui.navigation.headless-runner-plan", "owner": "Ui", "minimumCount": 14, "exactCount": 14, "requiredGroups": [] }, + { "id": "ui.navigation.selection-event-controls", "owner": "Ui", "minimumCount": 12, "exactCount": 12, + "requiredGroups": ["graph", "quick-chats", "project", "loop", "chat"] }, { "id": "ui.jump.workspace-destination-controls", "owner": "Ui", "minimumCount": 16, "exactCount": 16, "requiredGroups": [] }, { "id": "ui.jump.edit-buffer-controls", "owner": "Ui", "minimumCount": 8, "exactCount": 8, "requiredGroups": [] }, { "id": "ui.edit-buffer-cross-process", "owner": "Ui", "minimumCount": 4, "exactCount": 4, "requiredGroups": [] }, diff --git a/Tools/windows/ValidationRunner/README.md b/Tools/windows/ValidationRunner/README.md index d0420c8b..4c26433b 100644 --- a/Tools/windows/ValidationRunner/README.md +++ b/Tools/windows/ValidationRunner/README.md @@ -155,6 +155,18 @@ fresh fixture directory on success or failure. It sends no input, changes no focus and calls no UIA API. This fixture establishes the helper's cross-process behavior, not the complete Jump destination workflow. +The navigation oracle adapter also runs 12 headless event-receipt controls. +The live gate separately subscribes to real `ElementSelected` handlers while +selecting Graph, Quick Chats, a project, a loop and a chat, in that order. +Each non-idempotent transition has one original one-second observation window +and requires exactly one sender with the subscribed automation/runtime identity +and owned process, followed by the shared single-selection/container readback. +`UIA_NAVIGATION_SELECTION_EVENT_EVIDENCE` records each receipt; all five must +pass in the current-source native gate. The loop is restored after the sequence. +Worktrees events and the headless controls do not establish this live evidence. +This sequence covers navigation `Select`/`ElementSelected`, not additional +navigation event types or a broader accessibility claim. + UIA, packaging contracts, shell ownership, and managed/native tests consume those same implementations. The loader verifies the exact file/type, worktree, current source and compiler-reference fingerprints, and loaded module identity. diff --git a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs index 2c20fae9..19f4aed3 100644 --- a/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs +++ b/Tools/windows/ValidationRunner/Ui/Production/GraphCodeUiaGateState.cs @@ -74,6 +74,7 @@ private sealed class NativeInputOperations : IUiaInputOperations { public static string FocusSourceAutomationId; public static string SelectionSourceAutomationId; public static string SelectionSourceRuntimeId; + public static int SelectionSourceProcessId; public static string TogglePropertySourceAutomationId; public static readonly AutomationEventHandler LiveHandler = HandleLive; public static readonly AutomationPropertyChangedEventHandler NamePropertyHandler = HandleNameProperty; @@ -107,6 +108,7 @@ private static void HandleSelected(object sender, AutomationEventArgs eventArgs) if (element != null) { SelectionSourceAutomationId = element.Current.AutomationId; SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + SelectionSourceProcessId = element.Current.ProcessId; } SelectedEvents++; } diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs index 45851b75..0a96016b 100644 --- a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs +++ b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs @@ -17,7 +17,7 @@ public static class SuiteRegistration "UI_CASE_RESULT=", new[] { "ui.navigation.headless-runner-plan" }, new[] { "-PlanOnly" }), new StandaloneSuites.CaseScriptSuite("ui.navigation-oracle-contracts", @"Tools\windows\Tests\NavigationSelectionOracle.Tests.ps1", - "UI_CASE_RESULT=", new[] { "ui.navigation.selection-snapshot-controls" }) + "UI_CASE_RESULT=", new[] { "ui.navigation.selection-snapshot-controls", "ui.navigation.selection-event-controls" }) }.Concat(UiAdapterCases.CreateSuites()).ToArray(); } diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 4b4c079b..49fb0140 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2083,6 +2083,69 @@ function Assert-UiaSingleNavigationSelection($root, $walker, [string] $expectedI } | ConvertTo-Json -Compress -Depth 6)) } +function Assert-UiaNavigationSelectionEventReceipt($Receipt, [string] $ExpectedId, [string] $ExpectedRuntime, [int] $ProcessId) { + if (-not (Test-UiaCanonicalNavigationId $ExpectedId) -or $ProcessId -le 0 -or + [string]::IsNullOrWhiteSpace($ExpectedRuntime) -or $Receipt.count -ne 1 -or + $Receipt.id -cne $ExpectedId -or $Receipt.runtime -cne $ExpectedRuntime -or + $Receipt.process -ne $ProcessId) { + throw "Navigation event: expected exactly one owned canonical ElementSelected sender with the subscribed runtime identity" + } +} + +function Assert-UiaNavigationSelectionEvents($root, $walker, [string[]] $Ids, [int] $ProcessId) { + Require ($Ids.Count -eq 5 -and @($Ids | Sort-Object -Unique).Count -eq 5) ` + "Navigation events require five distinct canonical destinations" + foreach ($id in $Ids) { + Require (Test-UiaCanonicalNavigationId $id) "Navigation event target is an action alias" + $target = Find-FragmentById $root $id $walker + Require ($null -ne $target -and $target.Current.ProcessId -eq $ProcessId) ` + "Navigation event target is missing or foreign" + $runtime = Get-RuntimeIdentity $target + $item = $target.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + Require (-not $item.Current.IsSelected) "Navigation event must measure a transition, not idempotent Select" + $event = [System.Windows.Automation.SelectionItemPattern]::ElementSelectedEvent + $handler = [GraphCodeUiaGateState]::SelectedHandler + [GraphCodeUiaGateState]::SelectedEvents = 0 + [GraphCodeUiaGateState]::SelectionSourceAutomationId = $null + [GraphCodeUiaGateState]::SelectionSourceRuntimeId = $null + [GraphCodeUiaGateState]::SelectionSourceProcessId = 0 + $subscribed = $false + $primary = $null + try { + [System.Windows.Automation.Automation]::AddAutomationEventHandler( + $event, $target, [System.Windows.Automation.TreeScope]::Element, $handler) + $subscribed = $true + $clock = [Diagnostics.Stopwatch]::StartNew() + $item.Select() + while ($clock.ElapsedMilliseconds -lt 1000) { Start-Sleep -Milliseconds 25 } + $receipt = [pscustomobject]@{ + count = [GraphCodeUiaGateState]::SelectedEvents + id = [GraphCodeUiaGateState]::SelectionSourceAutomationId + runtime = [GraphCodeUiaGateState]::SelectionSourceRuntimeId + process = [GraphCodeUiaGateState]::SelectionSourceProcessId + } + Write-Host ("UIA_NAVIGATION_SELECTION_EVENT_EVIDENCE=" + ([ordered]@{ + expected = $id; expectedRuntime = $runtime; process = $ProcessId + event = "ElementSelected"; budgetMilliseconds = 1000; receipt = $receipt + } | ConvertTo-Json -Compress -Depth 4)) + Assert-UiaNavigationSelectionEventReceipt $receipt $id $runtime $ProcessId + Assert-UiaSingleNavigationSelection $root $walker $id $ProcessId + } catch { + $primary = $_ + throw + } finally { + if ($subscribed) { + try { + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($event, $target, $handler) + } catch { + if ($primary) { $primary.Exception.Data["NavigationEventUnsubscribeFailure"] = $_.Exception } + else { throw } + } + } + } + } +} + function Get-SidebarNavigationChildren( [System.Windows.Automation.AutomationElement] $root, [System.Windows.Automation.AutomationElement] $projects, @@ -8042,6 +8105,9 @@ try { Assert-UiaSingleNavigationSelection $root $rawWalker $dynamicInvokedProjectRow $process.Id $activeLoopRow.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() Assert-UiaSingleNavigationSelection $root $rawWalker $dynamicInvokedLoopRow $process.Id + Assert-UiaNavigationSelectionEvents $root $rawWalker @("overview-destination", "quick-chats-destination", $dynamicInvokedProjectRow, $dynamicInvokedLoopRow, $firstQuickRowId) $process.Id + $activeLoopRow = Find-FragmentById $root $dynamicInvokedLoopRow $rawWalker + $activeLoopRow.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() Start-Sleep -Milliseconds 250 $process.Refresh() $dynamicInvocationHasExited = $process.HasExited From 8a389981cef121d3789a0d9e1af1301103ab492b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 16:16:58 -0700 Subject: [PATCH 08/16] Align multi-project selection oracle with the active surface Require only project selection on project surfaces and only loop selection on workspace surfaces; cover all 32 surface/owner/selection combinations without weakening roster checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit 6d0211fb1ba67986990dc12a127667fcb4568f7e) --- Tools/windows/ValidationRunner/README.md | 6 ++++ .../Ui/MixedPolicyContracts.ps1 | 28 ++++++++++++++++--- Tools/windows/uia-live-gate.ps1 | 3 +- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Tools/windows/ValidationRunner/README.md b/Tools/windows/ValidationRunner/README.md index 4c26433b..5de1a9ab 100644 --- a/Tools/windows/ValidationRunner/README.md +++ b/Tools/windows/ValidationRunner/README.md @@ -166,6 +166,12 @@ pass in the current-source native gate. The loop is restored after the sequence. Worktrees events and the headless controls do not establish this live evidence. This sequence covers navigation `Select`/`ElementSelected`, not additional navigation event types or a broader accessibility claim. +The multi-project owner predicate uses the same canonical rule: project surfaces +select only the owning project row; workspace surfaces select only its loop row. +Overview, Quick Chats and other owners select neither of those rows. The +`ownership.mixed-ui-policy` suite checks all 32 surface/owner/selection combinations +without changing the independent roster, card, metadata, bounds or foreign-owner +requirements. UIA, packaging contracts, shell ownership, and managed/native tests consume those same implementations. The loader verifies the exact file/type, worktree, diff --git a/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 index d30d7bc3..35c04867 100644 --- a/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/MixedPolicyContracts.ps1 @@ -317,18 +317,38 @@ foreach($mutation in @('project-only','missing-loop','missing-toolbar','wrong-pr } Invoke-Mixed 3524 'project-refuses-workspace-chrome' {Assert-Mixed (-not (Test-MultiProjectObservedSurface project $true (New-MixedWorkspace) $workspaceOwner 4242)) 'Project accepted workspace chrome'} foreach($test in @( - @{line=3530;surface='project';owner=0;project=$true;loop=$true;expected=$true}, + @{line=3530;surface='project';owner=0;project=$true;loop=$false;expected=$true}, @{line=3535;surface='project';owner=1;project=$false;loop=$false;expected=$true}, @{line=3540;surface='workspace';owner=0;project=$false;loop=$true;expected=$true}, - @{line=3545;surface='overview';owner=0;project=$false;loop=$true;expected=$true}, - @{line=3550;surface='quick_chats';owner=0;project=$false;loop=$true;expected=$true}, + @{line=3545;surface='overview';owner=0;project=$false;loop=$false;expected=$true}, + @{line=3550;surface='quick_chats';owner=0;project=$false;loop=$false;expected=$true}, @{line=3555;surface='workspace';owner=0;project=$true;loop=$true;expected=$false}, @{line=3560;surface='project';owner=0;project=$false;loop=$true;expected=$false}, @{line=3565;surface='overview';owner=0;project=$true;loop=$true;expected=$false}, @{line=3570;surface='quick_chats';owner=0;project=$true;loop=$true;expected=$false}, @{line=3575;surface='workspace';owner=0;project=$false;loop=$false;expected=$false} )) { - Invoke-Mixed $test.line $test.surface {Assert-Mixed ((Test-MultiProjectOwnerSelection $test.surface $rosterOwners[$test.owner] $workspaceOwner $test.project $test.loop) -eq $test.expected) 'Owner/loop selection boundary changed'} + Invoke-Mixed $test.line $test.surface { + Assert-Mixed ((Test-MultiProjectOwnerSelection $test.surface $rosterOwners[$test.owner] $workspaceOwner $test.project $test.loop) -eq $test.expected) 'Owner/loop selection boundary changed' + if ($test.line -eq 3530) { + $truthRows = 0 + foreach ($surface in @('project', 'workspace', 'overview', 'quick_chats')) { + foreach ($ownerIndex in @(0, 1)) { + foreach ($projectSelected in @($false, $true)) { + foreach ($loopSelected in @($false, $true)) { + $expectedProject = $surface -ceq 'project' -and $ownerIndex -eq 0 + $expectedLoop = $surface -ceq 'workspace' -and $ownerIndex -eq 0 + $expected = $projectSelected -eq $expectedProject -and $loopSelected -eq $expectedLoop + Assert-Mixed ((Test-MultiProjectOwnerSelection $surface $rosterOwners[$ownerIndex] $workspaceOwner $projectSelected $loopSelected) -eq $expected) ` + "Surface/owner truth table differs: $surface owner=$ownerIndex project=$projectSelected loop=$loopSelected" + $truthRows++ + } + } + } + } + Assert-Mixed ($truthRows -eq 32) 'Owner selection truth table must execute all 32 combinations' + } + } } Invoke-Mixed 3596 'complete-two-owner-four-fragment-roster' {Assert-Mixed (Test-MultiProjectObservedRoster overview (New-MixedRoster) $rosterOwners $null 4242) 'Complete roster rejected'} Invoke-Mixed 3601 'obsolete-node-dimensions-refused' { diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 49fb0140..037a56c4 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -3941,7 +3941,8 @@ function Test-MultiProjectOwnerSelection([string] $surface, $owner, $selectedOwn $selected = $false if ($null -ne $selectedOwner) { $selected = $selectedOwner.path -ceq $owner.path } $expectedProjectSelected = $surface -ceq "project" -and $selected - return $projectSelected -eq $expectedProjectSelected -and $loopSelected -eq $selected + $expectedLoopSelected = $surface -ceq "workspace" -and $selected + return $projectSelected -eq $expectedProjectSelected -and $loopSelected -eq $expectedLoopSelected } function Test-MultiProjectObservedRoster([string] $surface, $projection, $expectedOwners, $selectedOwner, [int] $processId) { From e8223743604fc207379bd520c4e023ae22d39ea4 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 17:00:40 -0700 Subject: [PATCH 09/16] Trace acceptance-gated edge edit dispatch without changing behavior Add bounded diagnostic receipts for popup return/action dispatch, App callback target checks and selection, and edge-edit guards/modal entry. Require both UIA gate and explicit edge trace flags, avoid raw identity/draft text, and retain existing dispatch and refusal semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/README.md | 13 ++ graphcode-windows/src/App.zig | 114 +++++++++++++++++- graphcode-windows/src/Diagnostics.zig | 131 +++++++++++++++++++++ graphcode-windows/src/EdgeEditing.zig | 109 ++++++++++++++++- graphcode-windows/src/GraphContextMenu.zig | 79 ++++++++++++- 5 files changed, 434 insertions(+), 12 deletions(-) diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index e5734297..ace63359 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -1,5 +1,18 @@ # GraphCode Windows shell +For an explicitly admitted Edit Edge dispatch diagnostic, set both process-only +`GRAPHCODE_UIA_GATE=1` and `GRAPHCODE_UIA_EDGE_ACTION_TRACE=1`. Without both exact +values the receipt logger is inactive. It emits at most 128 `EDGE_ACTION` records +to the existing stderr logger, covering popup return/action dispatch, App callback +and stable-target refusal/selection, edge-edit guard results, and modal show entry. +Identity fields contain hashes and byte counts, not project paths, titles, endpoint +text, or draft contents. Sequence numbers are process-local. +This only observes the existing path: it adds no command handler, input, retry, +guard bypass, dispatch fallback, or change to editing results. Missing receipts +after the trace budget is exhausted cannot establish that an action did not run. +Focused headless receipt tests and compile checks do not attribute the previously +observed missing native Edit Edge modal or qualify a GUI rerun. + For a separately admitted receiver diagnostic, build with `-Dwire-receiver-trace=true`. The default is false. This uses the existing stderr logger for at most 64 received mouse move/down/up, capture-change, cancel-mode, diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 2a43c58a..662da30e 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -5209,17 +5209,32 @@ pub const App = struct { } fn editSelectedEdge(self: *App, index: usize) void { + Diagnostics.traceEdgeAction(.{ + .stage = .edit_entry, + .project = Diagnostics.EdgeActionTrace.Identity.from(self.selected_edge_project_path), + .edge = Diagnostics.EdgeActionTrace.Identity.from(self.selected_edge_id), + .index = index, + .hwnd = @intFromPtr(self.window.hwnd), + }); const result = EdgeEditing.edit(self.allocator, &self.model, &self.client, index, .{ .context = self, .show = showEdgeEditor }) catch |err| { + Diagnostics.traceEdgeAction(.{ .stage = .edit_return, .outcome = "error", .reason = @errorName(err), .index = index }); var message: [160]u8 = undefined; self.setStatus(std.fmt.bufPrint(&message, "Unable to edit edge: {s}", .{@errorName(err)}) catch "Unable to edit edge"); return; }; + Diagnostics.traceEdgeAction(.{ .stage = .edit_return, .outcome = @tagName(result), .index = index }); if (result == .queued) self.setStatus("Edge update queued; waiting for daemon"); } fn showEdgeEditor(context: ?*anyopaque, allocator: std.mem.Allocator, initial: Forms.EdgeDraft) !?Forms.EdgeDraft { const self: *App = @ptrCast(@alignCast(context.?)); - return NativeForms.editEdge(self.window.hwnd, allocator, initial); + Diagnostics.traceEdgeAction(.{ .stage = .modal_callback, .hwnd = @intFromPtr(self.window.hwnd) }); + const result = NativeForms.editEdge(self.window.hwnd, allocator, initial) catch |err| { + Diagnostics.traceEdgeAction(.{ .stage = .modal_return, .outcome = "error", .reason = @errorName(err) }); + return err; + }; + Diagnostics.traceEdgeAction(.{ .stage = .modal_return, .outcome = if (result == null) "cancelled" else "submitted" }); + return result; } fn deleteEdge(self: *App, index: usize) void { @@ -6077,11 +6092,46 @@ pub const App = struct { } }, .edge => |stable| { - const graph = self.model.graph orelse return; - if (!std.mem.eql(u8, graph.project.path, stable.project_path)) return; - if (stable.id.len == 0) return; - const index = GraphModel.findEdgeIndexByID(graph.edges.items, stable.id) orelse return; - if (!self.selectEdgeIndex(index)) return; + var receipt = Diagnostics.EdgeActionTrace.Event{ + .stage = .target_entry, + .project = Diagnostics.EdgeActionTrace.Identity.from(stable.project_path), + .edge = Diagnostics.EdgeActionTrace.Identity.from(stable.id), + .action = @tagName(action), + .hwnd = @intFromPtr(self.window.hwnd), + }; + Diagnostics.traceEdgeAction(receipt); + const graph = self.model.graph orelse { + receipt.stage = .target_refused; + receipt.reason = "MissingGraph"; + Diagnostics.traceEdgeAction(receipt); + return; + }; + if (!std.mem.eql(u8, graph.project.path, stable.project_path)) { + receipt.stage = .target_refused; + receipt.reason = "ProjectChanged"; + Diagnostics.traceEdgeAction(receipt); + return; + } + if (stable.id.len == 0) { + receipt.stage = .target_refused; + receipt.reason = "MissingEdgeIdentity"; + Diagnostics.traceEdgeAction(receipt); + return; + } + const index = GraphModel.findEdgeIndexByID(graph.edges.items, stable.id) orelse { + receipt.stage = .target_refused; + receipt.reason = "EdgeNotFound"; + Diagnostics.traceEdgeAction(receipt); + return; + }; + receipt.stage = .target_resolved; + receipt.index = index; + Diagnostics.traceEdgeAction(receipt); + const selected = self.selectEdgeIndex(index); + receipt.stage = .selection_result; + receipt.outcome = if (selected) "selected" else "refused"; + Diagnostics.traceEdgeAction(receipt); + if (!selected) return; switch (action) { .edit_edge => self.editSelectedEdge(index), .delete_edge => self.deleteEdge(index), @@ -10635,9 +10685,61 @@ fn onDaemonFrame( fn onContextAction(context: ?*anyopaque, action: GraphContextMenu.Action, target: GraphContextMenu.Target) void { const app: *App = @ptrCast(@alignCast(context.?)); + if (target == .edge) Diagnostics.traceEdgeAction(.{ + .stage = .callback_entry, + .project = Diagnostics.EdgeActionTrace.Identity.from(target.edge.project_path), + .edge = Diagnostics.EdgeActionTrace.Identity.from(target.edge.id), + .action = @tagName(action), + .hwnd = @intFromPtr(app.window.hwnd), + }); app.handleContextAction(action, target); } +test "edge action diagnostic records callback and stable target refusals without native work" { + const Probe = struct { + var events: [4]Diagnostics.EdgeActionTrace.Event = undefined; + var count: usize = 0; + + fn observe(event: Diagnostics.EdgeActionTrace.Event) void { + if (count < events.len) events[count] = event; + count += 1; + } + }; + const allocator = std.testing.allocator; + var app: App = .{ + .allocator = allocator, + .client = undefined, + .daemon = undefined, + .model = GraphModel.Model.init(allocator), + .sidebar_state = undefined, + .declared_entry_ids = undefined, + .kept_worktree_paths = undefined, + }; + defer app.model.deinit(); + Diagnostics.edge_action_test_observer = Probe.observe; + defer Diagnostics.edge_action_test_observer = null; + const cases = [_]struct { project: []const u8, id: []const u8, reason: []const u8 }{ + .{ .project = "A", .id = "edge-a", .reason = "MissingGraph" }, + .{ .project = "B", .id = "edge-a", .reason = "ProjectChanged" }, + .{ .project = "A", .id = "", .reason = "MissingEdgeIdentity" }, + .{ .project = "A", .id = "missing", .reason = "EdgeNotFound" }, + }; + for (cases, 0..) |case, index| { + if (index == 1) _ = try app.model.updateFromFrame( + \\{"event":{"graphChanged":{"project":{"path":"A","name":"A"},"nodes":[],"edges":[]}}} + ); + Probe.count = 0; + onContextAction(&app, .edit_edge, .{ .edge = .{ .project_path = case.project, .id = case.id } }); + try std.testing.expectEqual(@as(usize, 3), Probe.count); + try std.testing.expectEqual(Diagnostics.EdgeActionTrace.Stage.callback_entry, Probe.events[0].stage); + try std.testing.expectEqual(Diagnostics.EdgeActionTrace.Stage.target_entry, Probe.events[1].stage); + try std.testing.expectEqual(Diagnostics.EdgeActionTrace.Stage.target_refused, Probe.events[2].stage); + try std.testing.expectEqualStrings(case.reason, Probe.events[2].reason); + try std.testing.expectEqual(@as(usize, 0), app.selected_edge_id.len); + try std.testing.expectEqual(@as(?usize, null), app.canvas.selected_edge); + } +} + fn logWireReceiver(app: *App, event: u32, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM, after: bool) void { const point = if (message == c.WM_MOUSEMOVE or message == c.WM_LBUTTONDOWN or message == c.WM_LBUTTONUP) CanvasInput.decodeMouseMessage(lparam) diff --git a/graphcode-windows/src/Diagnostics.zig b/graphcode-windows/src/Diagnostics.zig index bdcb5508..66edb332 100644 --- a/graphcode-windows/src/Diagnostics.zig +++ b/graphcode-windows/src/Diagnostics.zig @@ -3,6 +3,137 @@ const std = @import("std"); const max_bytes = 2 * 1024 * 1024; var mutex: std.Thread.Mutex = .{}; +pub const EdgeActionTrace = struct { + pub const max_events = 128; + pub const Identity = struct { + hash: u64 = 0, + bytes: usize = 0, + + pub fn from(value: []const u8) Identity { + return .{ .hash = std.hash.Wyhash.hash(0, value), .bytes = value.len }; + } + }; + pub const Stage = enum { + popup_entry, + popup_return, + popup_dispatch, + callback_entry, + target_entry, + target_resolved, + target_refused, + selection_result, + edit_entry, + guard_entry, + guard_refused, + guards_passed, + modal_show, + modal_callback, + modal_return, + edit_return, + }; + pub const Event = struct { + stage: Stage, + project: Identity = .{}, + edge: Identity = .{}, + action: []const u8 = "none", + command: i64 = 0, + hwnd: usize = 0, + index: ?usize = null, + guard: []const u8 = "none", + outcome: []const u8 = "none", + reason: []const u8 = "none", + }; + events: u32 = 0, + + pub fn enabled(gate: ?[]const u8, trace: ?[]const u8) bool { + return gate != null and trace != null and + std.mem.eql(u8, gate.?, "1") and std.mem.eql(u8, trace.?, "1"); + } + + pub fn project(self: *EdgeActionTrace, buffer: []u8, event: Event) !?[]const u8 { + if (self.events >= max_events) return null; + self.events += 1; + return try std.fmt.bufPrint(buffer, "EDGE_ACTION seq={d} stage={s} action={s} command={d} hwnd=0x{x} project_hash={x} project_bytes={d} edge_hash={x} edge_bytes={d} has_index={} index={d} guard={s} outcome={s} reason={s}", .{ + self.events, @tagName(event.stage), event.action[0..@min(event.action.len, 32)], + event.command, event.hwnd, event.project.hash, + event.project.bytes, event.edge.hash, event.edge.bytes, + event.index != null, event.index orelse 0, event.guard[0..@min(event.guard.len, 32)], + event.outcome[0..@min(event.outcome.len, 24)], event.reason[0..@min(event.reason.len, 64)], + }); + } +}; + +var edge_action_trace: EdgeActionTrace = .{}; +pub var edge_action_test_observer: ?*const fn (EdgeActionTrace.Event) void = null; + +pub fn traceEdgeAction(event: EdgeActionTrace.Event) void { + if (@import("builtin").is_test) { + if (edge_action_test_observer) |observe| { + observe(event); + return; + } + } + const gate = edgeTraceValue("GRAPHCODE_UIA_GATE") orelse return; + defer std.heap.page_allocator.free(gate); + if (!std.mem.eql(u8, gate, "1")) return; + const trace = edgeTraceValue("GRAPHCODE_UIA_EDGE_ACTION_TRACE") orelse return; + defer std.heap.page_allocator.free(trace); + if (!EdgeActionTrace.enabled(gate, trace)) return; + mutex.lock(); + defer mutex.unlock(); + var buffer: [768]u8 = undefined; + const line = edge_action_trace.project(&buffer, event) catch |err| { + std.log.err("Edge action diagnostic projection failed: {s}", .{@errorName(err)}); + return; + }; + if (line) |value| std.log.info("{s}", .{value}); +} + +fn edgeTraceValue(name: []const u8) ?[]u8 { + const value = std.process.getEnvVarOwned(std.heap.page_allocator, name) catch |err| { + if (err != error.EnvironmentVariableNotFound) + std.log.err("Edge action diagnostic flag read failed: {s}", .{@errorName(err)}); + return null; + }; + return value; +} + +test "edge action diagnostic requires both exact acceptance flags" { + try std.testing.expect(EdgeActionTrace.enabled("1", "1")); + for ([_]?[]const u8{ null, "", "0", "true", "01", "1 " }) |value| { + try std.testing.expect(!EdgeActionTrace.enabled(value, "1")); + try std.testing.expect(!EdgeActionTrace.enabled("1", value)); + } +} + +test "edge action diagnostic has bounded ordered receipts without private identity text" { + var trace = EdgeActionTrace{}; + var buffer: [768]u8 = undefined; + const identity = EdgeActionTrace.Identity.from("private\\project\nedge"); + const event = EdgeActionTrace.Event{ + .stage = .popup_return, + .project = identity, + .edge = EdgeActionTrace.Identity.from("edge-a"), + .command = 5110, + .action = "edit_edge", + .hwnd = 123, + .index = 4, + }; + const first = (try trace.project(&buffer, event)) orelse return error.MissingEdgeDiagnostic; + try std.testing.expect(std.mem.startsWith(u8, first, "EDGE_ACTION seq=1 stage=popup_return")); + try std.testing.expect(std.mem.indexOf(u8, first, "command=5110") != null); + try std.testing.expect(std.mem.indexOf(u8, first, "private") == null); + try std.testing.expect(std.mem.indexOfScalar(u8, first, '\n') == null); + try std.testing.expect(first.len < buffer.len); + const second = (try trace.project(&buffer, .{ .stage = .guard_refused, .guard = "scope", .reason = "StaleScope" })) orelse + return error.MissingEdgeDiagnostic; + try std.testing.expect(std.mem.startsWith(u8, second, "EDGE_ACTION seq=2 stage=guard_refused")); + try std.testing.expect(std.mem.indexOf(u8, second, "guard=scope") != null); + try std.testing.expect(std.mem.indexOf(u8, second, "reason=StaleScope") != null); + for (2..EdgeActionTrace.max_events) |_| _ = try trace.project(&buffer, event); + try std.testing.expectEqual(@as(?[]const u8, null), try trace.project(&buffer, event)); +} + pub const WireReceiverTrace = struct { pub const max_events = 64; events: u32 = 0, diff --git a/graphcode-windows/src/EdgeEditing.zig b/graphcode-windows/src/EdgeEditing.zig index 6607f01f..2c6bae9b 100644 --- a/graphcode-windows/src/EdgeEditing.zig +++ b/graphcode-windows/src/EdgeEditing.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Forms = @import("Forms.zig"); const GraphModel = @import("GraphModel.zig"); const DaemonClient = @import("DaemonClient.zig").DaemonClient; +const Diagnostics = @import("Diagnostics.zig"); pub const Modal = struct { context: ?*anyopaque = null, @@ -63,25 +64,61 @@ fn checkCurrentEdge(allocator: std.mem.Allocator, model: *const GraphModel.Model } pub fn edit(allocator: std.mem.Allocator, model: *GraphModel.Model, client: *DaemonClient, index: usize, modal: Modal) !Result { + var receipt = Diagnostics.EdgeActionTrace.Event{ + .stage = .guard_entry, + .project = Diagnostics.EdgeActionTrace.Identity.from(model.selected_project_path orelse ""), + .index = index, + .guard = "scope", + }; + var modal_entered = false; + Diagnostics.traceEdgeAction(receipt); + errdefer |err| { + receipt.stage = .guard_refused; + receipt.reason = @errorName(err); + receipt.outcome = if (modal_entered) "after_modal" else "before_modal"; + Diagnostics.traceEdgeAction(receipt); + } const scope = try Scope.capture(allocator, model, client); defer scope.deinit(allocator); + receipt.guard = "graph"; const graph = model.graph orelse return error.MissingGraph; + receipt.guard = "index"; if (index >= graph.edges.items.len) return error.MissingEdge; + receipt.guard = "clone"; const edge = try GraphModel.cloneEdge(allocator, graph.edges.items[index]); defer GraphModel.freeEdge(allocator, edge); + receipt.edge = Diagnostics.EdgeActionTrace.Identity.from(edge.id); + receipt.guard = "identity"; if (edge.id.len == 0) return error.MissingEdgeIdentity; + receipt.guard = "editable"; if (!edge.editable_configuration) return error.UnsupportedEdgeConfiguration; + receipt.guard = "current"; try checkCurrentEdge(allocator, model, edge); const expected = Forms.EdgeConfiguration.fromEdge(edge); const initial = expected.draft(edge.from, edge.to); + receipt.guard = "validate"; try Forms.validateEdgeEdit(initial, initial); - var draft = try modal.show(modal.context, allocator, initial) orelse return .cancelled; + receipt.stage = .guards_passed; + Diagnostics.traceEdgeAction(receipt); + receipt.stage = .modal_show; + receipt.guard = "show"; + Diagnostics.traceEdgeAction(receipt); + modal_entered = true; + const shown = try modal.show(modal.context, allocator, initial); + receipt.stage = .modal_return; + receipt.outcome = if (shown == null) "cancelled" else "submitted"; + Diagnostics.traceEdgeAction(receipt); + var draft = shown orelse return .cancelled; defer draft.deinit(allocator); + receipt.guard = "scope_recheck"; try scope.check(model, client); + receipt.guard = "current_recheck"; try checkCurrentEdge(allocator, model, edge); + receipt.guard = "validate_recheck"; try Forms.validateEdgeEdit(initial, draft); const replacement = expected.applying(draft); if (Forms.EdgeConfiguration.eql(expected, replacement)) return .unchanged; + receipt.guard = "queue"; try client.sendUpdateEdge(scope.project, edge.id, edge.from, edge.to, expected, replacement, scope.composite); return .queued; } @@ -94,6 +131,76 @@ fn loadFixture(model: *GraphModel.Model) !void { _ = try model.updateFromFrame(fixture); } +test "edge editing diagnostic reports guard refusal without opening the modal" { + const Probe = struct { + var events: [8]Diagnostics.EdgeActionTrace.Event = undefined; + var count: usize = 0; + var modal_calls: usize = 0; + + fn observe(event: Diagnostics.EdgeActionTrace.Event) void { + if (count < events.len) events[count] = event; + count += 1; + } + + fn show(_: ?*anyopaque, _: std.mem.Allocator, _: Forms.EdgeDraft) !?Forms.EdgeDraft { + modal_calls += 1; + return null; + } + }; + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + try loadFixture(&model); + var client = try DaemonClient.initForTesting(std.testing.allocator); + defer client.deinit(); + Diagnostics.edge_action_test_observer = Probe.observe; + defer Diagnostics.edge_action_test_observer = null; + Probe.count = 0; + Probe.modal_calls = 0; + try std.testing.expectError(error.MissingEdge, edit(std.testing.allocator, &model, &client, 99, .{ .show = Probe.show })); + try std.testing.expectEqual(@as(usize, 0), Probe.modal_calls); + try std.testing.expectEqual(@as(usize, 0), client.outbound_count); + try std.testing.expectEqual(@as(usize, 2), Probe.count); + try std.testing.expectEqual(Diagnostics.EdgeActionTrace.Stage.guard_entry, Probe.events[0].stage); + try std.testing.expectEqual(Diagnostics.EdgeActionTrace.Stage.guard_refused, Probe.events[1].stage); + try std.testing.expectEqualStrings("index", Probe.events[1].guard); + try std.testing.expectEqualStrings("MissingEdge", Probe.events[1].reason); + try std.testing.expectEqualStrings("before_modal", Probe.events[1].outcome); +} + +test "edge editing diagnostic records guards and show entry without changing cancellation" { + const Probe = struct { + var stages: [8]Diagnostics.EdgeActionTrace.Stage = undefined; + var count: usize = 0; + var modal_calls: usize = 0; + + fn observe(event: Diagnostics.EdgeActionTrace.Event) void { + if (count < stages.len) stages[count] = event.stage; + count += 1; + } + + fn show(_: ?*anyopaque, _: std.mem.Allocator, _: Forms.EdgeDraft) !?Forms.EdgeDraft { + try std.testing.expectEqual(@as(usize, 3), count); + try std.testing.expectEqual(Diagnostics.EdgeActionTrace.Stage.modal_show, stages[2]); + modal_calls += 1; + return null; + } + }; + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + try loadFixture(&model); + var client = try DaemonClient.initForTesting(std.testing.allocator); + defer client.deinit(); + Diagnostics.edge_action_test_observer = Probe.observe; + defer Diagnostics.edge_action_test_observer = null; + Probe.count = 0; + Probe.modal_calls = 0; + try std.testing.expectEqual(Result.cancelled, try edit(std.testing.allocator, &model, &client, 0, .{ .show = Probe.show })); + try std.testing.expectEqual(@as(usize, 1), Probe.modal_calls); + try std.testing.expectEqual(@as(usize, 0), client.outbound_count); + try std.testing.expectEqual(@as(usize, 4), Probe.count); + try std.testing.expectEqualSlices(Diagnostics.EdgeActionTrace.Stage, &.{ .guard_entry, .guards_passed, .modal_show, .modal_return }, Probe.stages[0..Probe.count]); +} + fn acceptKind(_: ?*anyopaque, allocator: std.mem.Allocator, initial: Forms.EdgeDraft) !?Forms.EdgeDraft { const from = try allocator.dupe(u8, initial.from); errdefer allocator.free(from); diff --git a/graphcode-windows/src/GraphContextMenu.zig b/graphcode-windows/src/GraphContextMenu.zig index 74c76fd8..31f9bb5b 100644 --- a/graphcode-windows/src/GraphContextMenu.zig +++ b/graphcode-windows/src/GraphContextMenu.zig @@ -2,6 +2,7 @@ const c = @import("Win32.zig").c; const Wire = @import("Wire.zig"); const SketchPromotion = @import("SketchPromotion.zig"); const NativeDialogs = @import("WindowsNativeDialogs.zig"); +const Diagnostics = @import("Diagnostics.zig"); pub const NodeMenuOrigin = enum { canvas, sidebar }; pub const TransferMenuState = enum { hidden, ready, local_only, busy, context_changed }; @@ -320,7 +321,16 @@ pub fn show( context: ?*anyopaque, callback: Callback, ) void { - const menu = buildMenu(target) orelse return; + if (target == .edge) Diagnostics.traceEdgeAction(.{ + .stage = .popup_entry, + .project = Diagnostics.EdgeActionTrace.Identity.from(target.edge.project_path), + .edge = Diagnostics.EdgeActionTrace.Identity.from(target.edge.id), + .hwnd = @intFromPtr(parent), + }); + const menu = buildMenu(target) orelse { + if (target == .edge) Diagnostics.traceEdgeAction(.{ .stage = .target_refused, .reason = "MenuCreationFailed" }); + return; + }; defer _ = c.DestroyMenu(menu); const command = c.TrackPopupMenu( menu, @@ -331,8 +341,67 @@ pub fn show( parent, null, ); + dispatchReturnedCommand(parent, target, command, context, callback); +} + +fn dispatchReturnedCommand(parent: c.HWND, target: Target, command: c.BOOL, context: ?*anyopaque, callback: Callback) void { const action = actionForCommand(command); - if (action != .none) callback(context, action, target); + if (target == .edge) Diagnostics.traceEdgeAction(.{ + .stage = .popup_return, + .project = Diagnostics.EdgeActionTrace.Identity.from(target.edge.project_path), + .edge = Diagnostics.EdgeActionTrace.Identity.from(target.edge.id), + .hwnd = @intFromPtr(parent), + .command = command, + .action = @tagName(action), + }); + if (action != .none) { + if (target == .edge) Diagnostics.traceEdgeAction(.{ + .stage = .popup_dispatch, + .project = Diagnostics.EdgeActionTrace.Identity.from(target.edge.project_path), + .edge = Diagnostics.EdgeActionTrace.Identity.from(target.edge.id), + .command = command, + .action = @tagName(action), + }); + callback(context, action, target); + } +} + +test "edge action diagnostic observes popup return without changing dispatch" { + const Probe = struct { + var stages: [4]Diagnostics.EdgeActionTrace.Stage = undefined; + var count: usize = 0; + var callbacks: usize = 0; + var action: Action = .none; + var target_matches: bool = false; + + fn observe(event: Diagnostics.EdgeActionTrace.Event) void { + if (count < stages.len) stages[count] = event.stage; + count += 1; + } + + fn apply(_: ?*anyopaque, selected: Action, target: Target) void { + callbacks += 1; + action = selected; + target_matches = std.mem.eql(u8, target.edge.id, "edge-a") and std.mem.eql(u8, target.edge.project_path, "A"); + } + }; + Diagnostics.edge_action_test_observer = Probe.observe; + defer Diagnostics.edge_action_test_observer = null; + const target = Target{ .edge = .{ .project_path = "A", .id = "edge-a" } }; + for ([_]c.BOOL{ 0, ids.edit_edge, ids.delete_edge, -1 }) |command| { + Probe.count = 0; + Probe.callbacks = 0; + Probe.action = .none; + Probe.target_matches = false; + dispatchReturnedCommand(null, target, command, null, Probe.apply); + const expected = actionForCommand(command); + try std.testing.expectEqual(expected, Probe.action); + try std.testing.expectEqual(@as(usize, if (expected == .none) 0 else 1), Probe.callbacks); + try std.testing.expectEqual(@as(usize, if (expected == .none) 1 else 2), Probe.count); + try std.testing.expectEqual(expected != .none, Probe.target_matches); + try std.testing.expectEqual(Diagnostics.EdgeActionTrace.Stage.popup_return, Probe.stages[0]); + if (expected != .none) try std.testing.expectEqual(Diagnostics.EdgeActionTrace.Stage.popup_dispatch, Probe.stages[1]); + } } fn buildMenu(target: Target) c.HMENU { @@ -532,8 +601,7 @@ const std = @import("std"); test "card transfer menu has executable stable actions on canvas and sidebar root rows" { for ([_]NodeMenuOrigin{ .canvas, .sidebar }) |origin| { const plan = nodeMenuPlan(.{ - .project_path = "C:\\A", - .id = "node", + .project_path = "C:\\A", .id = "node", .retype = .{ .origin = origin }, .transfer = .ready, }); @@ -579,7 +647,8 @@ test "card transfer menu capacity covers every flag combination without losing r var maximum: usize = 0; for (0..512) |bits| { const plan = nodeMenuPlan(.{ - .project_path = "C:\\A", .id = "node", + .project_path = "C:\\A", + .id = "node", .composite = bits & 1 != 0, .can_arm = bits & 2 != 0, .unwired = bits & 4 != 0, From 50c2de380e499c8aa78ade30841d8389211b6307 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 17:01:28 -0700 Subject: [PATCH 10/16] Keep edge trace patch free of unrelated formatting Restore pre-existing card-transfer fixture formatting; diagnostic behavior and assertions are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/GraphContextMenu.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphcode-windows/src/GraphContextMenu.zig b/graphcode-windows/src/GraphContextMenu.zig index 31f9bb5b..8e3b9672 100644 --- a/graphcode-windows/src/GraphContextMenu.zig +++ b/graphcode-windows/src/GraphContextMenu.zig @@ -601,7 +601,8 @@ const std = @import("std"); test "card transfer menu has executable stable actions on canvas and sidebar root rows" { for ([_]NodeMenuOrigin{ .canvas, .sidebar }) |origin| { const plan = nodeMenuPlan(.{ - .project_path = "C:\\A", .id = "node", + .project_path = "C:\\A", + .id = "node", .retype = .{ .origin = origin }, .transfer = .ready, }); @@ -647,8 +648,7 @@ test "card transfer menu capacity covers every flag combination without losing r var maximum: usize = 0; for (0..512) |bits| { const plan = nodeMenuPlan(.{ - .project_path = "C:\\A", - .id = "node", + .project_path = "C:\\A", .id = "node", .composite = bits & 1 != 0, .can_arm = bits & 2 != 0, .unwired = bits & 4 != 0, From 9fcb52bac6d4f9d389f25c7f757dc9b0dcac28d2 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 17:32:21 -0700 Subject: [PATCH 11/16] Observe native edge popup context without changing input or dispatch Gate bounded thread-local focus, capture, foreground and cancel/activation receipts around TrackPopupMenu behind the existing diagnostic flags. A zero return still dispatches nothing. No hooks, input, retry or guard changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/README.md | 5 ++ graphcode-windows/src/App.zig | 1 + graphcode-windows/src/Diagnostics.zig | 63 +++++++++++++++--- graphcode-windows/src/GraphContextMenu.zig | 76 +++++++++++++++++++--- 4 files changed, 127 insertions(+), 18 deletions(-) diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index ace63359..5dc6cf5e 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -7,6 +7,11 @@ to the existing stderr logger, covering popup return/action dispatch, App callba and stable-target refusal/selection, edge-edit guard results, and modal show entry. Identity fields contain hashes and byte counts, not project paths, titles, endpoint text, or draft contents. Sequence numbers are process-local. +While the edge popup is active, additional records capture monotonic native +ticks, its GUI thread's focus/capture/menu owner, foreground handle/process, +and delivered cancel/activate/capture/focus messages. These are point-in-time +observations, not proof of what caused cancellation. A zero `TrackPopupMenu` +return remains cancellation and never causes an edit callback. This only observes the existing path: it adds no command handler, input, retry, guard bypass, dispatch fallback, or change to editing results. Missing receipts after the trace budget is exhausted cannot establish that an action did not run. diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 662da30e..426f500f 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -10766,6 +10766,7 @@ fn onWindowMessage( result: *c.LRESULT, ) callconv(.c) bool { const app: *App = @ptrCast(@alignCast(context.?)); + GraphContextMenu.traceEdgePopupMessage(message, wparam, lparam); const trace_event = if (build_options.wire_receiver_trace and Diagnostics.WireReceiverTrace.relevant(message)) app.wire_receiver_trace.begin() else diff --git a/graphcode-windows/src/Diagnostics.zig b/graphcode-windows/src/Diagnostics.zig index 66edb332..976a4c48 100644 --- a/graphcode-windows/src/Diagnostics.zig +++ b/graphcode-windows/src/Diagnostics.zig @@ -17,6 +17,7 @@ pub const EdgeActionTrace = struct { popup_entry, popup_return, popup_dispatch, + popup_message, callback_entry, target_entry, target_resolved, @@ -42,6 +43,18 @@ pub const EdgeActionTrace = struct { guard: []const u8 = "none", outcome: []const u8 = "none", reason: []const u8 = "none", + tick_ms: u64 = 0, + thread_id: u32 = 0, + gui_available: bool = false, + gui_flags: u32 = 0, + foreground: usize = 0, + foreground_pid: u32 = 0, + focus: usize = 0, + capture: usize = 0, + menu_owner: usize = 0, + message: u32 = 0, + wparam: usize = 0, + lparam: isize = 0, }; events: u32 = 0, @@ -53,12 +66,16 @@ pub const EdgeActionTrace = struct { pub fn project(self: *EdgeActionTrace, buffer: []u8, event: Event) !?[]const u8 { if (self.events >= max_events) return null; self.events += 1; - return try std.fmt.bufPrint(buffer, "EDGE_ACTION seq={d} stage={s} action={s} command={d} hwnd=0x{x} project_hash={x} project_bytes={d} edge_hash={x} edge_bytes={d} has_index={} index={d} guard={s} outcome={s} reason={s}", .{ + return try std.fmt.bufPrint(buffer, "EDGE_ACTION seq={d} stage={s} action={s} command={d} hwnd=0x{x} project_hash={x} project_bytes={d} edge_hash={x} edge_bytes={d} has_index={} index={d} guard={s} outcome={s} reason={s} tick_ms={d} thread={d} gui={} gui_flags=0x{x} foreground=0x{x} foreground_pid={d} focus=0x{x} capture=0x{x} menu_owner=0x{x} message=0x{x} wp=0x{x} lp={d}", .{ self.events, @tagName(event.stage), event.action[0..@min(event.action.len, 32)], event.command, event.hwnd, event.project.hash, event.project.bytes, event.edge.hash, event.edge.bytes, event.index != null, event.index orelse 0, event.guard[0..@min(event.guard.len, 32)], - event.outcome[0..@min(event.outcome.len, 24)], event.reason[0..@min(event.reason.len, 64)], + event.outcome[0..@min(event.outcome.len, 24)], event.reason[0..@min(event.reason.len, 64)], event.tick_ms, + event.thread_id, event.gui_available, event.gui_flags, + event.foreground, event.foreground_pid, event.focus, + event.capture, event.menu_owner, event.message, + event.wparam, event.lparam, }); } }; @@ -73,15 +90,10 @@ pub fn traceEdgeAction(event: EdgeActionTrace.Event) void { return; } } - const gate = edgeTraceValue("GRAPHCODE_UIA_GATE") orelse return; - defer std.heap.page_allocator.free(gate); - if (!std.mem.eql(u8, gate, "1")) return; - const trace = edgeTraceValue("GRAPHCODE_UIA_EDGE_ACTION_TRACE") orelse return; - defer std.heap.page_allocator.free(trace); - if (!EdgeActionTrace.enabled(gate, trace)) return; + if (!edgeActionTraceEnabled()) return; mutex.lock(); defer mutex.unlock(); - var buffer: [768]u8 = undefined; + var buffer: [1280]u8 = undefined; const line = edge_action_trace.project(&buffer, event) catch |err| { std.log.err("Edge action diagnostic projection failed: {s}", .{@errorName(err)}); return; @@ -89,6 +101,15 @@ pub fn traceEdgeAction(event: EdgeActionTrace.Event) void { if (line) |value| std.log.info("{s}", .{value}); } +pub fn edgeActionTraceEnabled() bool { + const gate = edgeTraceValue("GRAPHCODE_UIA_GATE") orelse return false; + defer std.heap.page_allocator.free(gate); + if (!std.mem.eql(u8, gate, "1")) return false; + const trace = edgeTraceValue("GRAPHCODE_UIA_EDGE_ACTION_TRACE") orelse return false; + defer std.heap.page_allocator.free(trace); + return EdgeActionTrace.enabled(gate, trace); +} + fn edgeTraceValue(name: []const u8) ?[]u8 { const value = std.process.getEnvVarOwned(std.heap.page_allocator, name) catch |err| { if (err != error.EnvironmentVariableNotFound) @@ -134,6 +155,30 @@ test "edge action diagnostic has bounded ordered receipts without private identi try std.testing.expectEqual(@as(?[]const u8, null), try trace.project(&buffer, event)); } +test "edge popup diagnostic projects native context and cancellation scalars without claiming selection" { + var trace = EdgeActionTrace{}; + var buffer: [1280]u8 = undefined; + const line = (try trace.project(&buffer, .{ + .stage = .popup_message, + .tick_ms = 345, + .thread_id = 17, + .gui_available = true, + .gui_flags = 4, + .foreground = 100, + .foreground_pid = 200, + .focus = 300, + .capture = 400, + .menu_owner = 500, + .message = 0x001f, + .wparam = 0, + .lparam = -1, + .outcome = "observed", + })) orelse return error.MissingEdgeDiagnostic; + for ([_][]const u8{ "tick_ms=345", "thread=17", "gui=true", "gui_flags=0x4", "foreground=0x64", "foreground_pid=200", "focus=0x12c", "capture=0x190", "menu_owner=0x1f4", "message=0x1f", "lp=-1", "outcome=observed" }) |field| + try std.testing.expect(std.mem.indexOf(u8, line, field) != null); + try std.testing.expect(std.mem.indexOf(u8, line, "selected") == null); +} + pub const WireReceiverTrace = struct { pub const max_events = 64; events: u32 = 0, diff --git a/graphcode-windows/src/GraphContextMenu.zig b/graphcode-windows/src/GraphContextMenu.zig index 8e3b9672..4f8e29c1 100644 --- a/graphcode-windows/src/GraphContextMenu.zig +++ b/graphcode-windows/src/GraphContextMenu.zig @@ -313,6 +313,41 @@ pub fn retypeTarget(action: Action) ?SketchPromotion.Target { }; } +threadlocal var edge_popup_receipt: ?Diagnostics.EdgeActionTrace.Event = null; + +fn popupContext(receipt: Diagnostics.EdgeActionTrace.Event) Diagnostics.EdgeActionTrace.Event { + var observed = receipt; + observed.tick_ms = c.GetTickCount64(); + observed.thread_id = c.GetCurrentThreadId(); + var gui = std.mem.zeroes(c.GUITHREADINFO); + gui.cbSize = @sizeOf(c.GUITHREADINFO); + observed.gui_available = c.GetGUIThreadInfo(observed.thread_id, &gui) != 0; + if (observed.gui_available) { + observed.gui_flags = gui.flags; + observed.focus = @intFromPtr(gui.hwndFocus); + observed.capture = @intFromPtr(gui.hwndCapture); + observed.menu_owner = @intFromPtr(gui.hwndMenuOwner); + } + const foreground = c.GetForegroundWindow(); + observed.foreground = @intFromPtr(foreground); + if (foreground != null) _ = c.GetWindowThreadProcessId(foreground, &observed.foreground_pid); + return observed; +} + +pub fn traceEdgePopupMessage(message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) void { + var receipt = edge_popup_receipt orelse return; + switch (message) { + c.WM_CANCELMODE, c.WM_ACTIVATE, c.WM_ACTIVATEAPP, c.WM_CAPTURECHANGED, c.WM_KILLFOCUS, c.WM_SETFOCUS => {}, + else => return, + } + receipt.stage = .popup_message; + receipt.message = message; + receipt.wparam = wparam; + receipt.lparam = lparam; + receipt.outcome = "observed"; + Diagnostics.traceEdgeAction(popupContext(receipt)); +} + pub fn show( parent: c.HWND, target: Target, @@ -332,15 +367,38 @@ pub fn show( return; }; defer _ = c.DestroyMenu(menu); - const command = c.TrackPopupMenu( - menu, - c.TPM_RETURNCMD | c.TPM_NONOTIFY | c.TPM_RIGHTBUTTON, - x, - y, - 0, - parent, - null, - ); + const command = track: { + const tracing = target == .edge and Diagnostics.edgeActionTraceEnabled(); + const previous = edge_popup_receipt; + if (tracing) { + edge_popup_receipt = .{ + .stage = .popup_entry, + .project = Diagnostics.EdgeActionTrace.Identity.from(target.edge.project_path), + .edge = Diagnostics.EdgeActionTrace.Identity.from(target.edge.id), + .hwnd = @intFromPtr(parent), + }; + Diagnostics.traceEdgeAction(popupContext(edge_popup_receipt.?)); + } + defer edge_popup_receipt = previous; + const returned = c.TrackPopupMenu( + menu, + c.TPM_RETURNCMD | c.TPM_NONOTIFY | c.TPM_RIGHTBUTTON, + x, + y, + 0, + parent, + null, + ); + if (tracing) { + var receipt = edge_popup_receipt.?; + receipt.stage = .popup_return; + receipt.command = returned; + receipt.action = @tagName(actionForCommand(returned)); + receipt.outcome = if (returned == 0) "cancelled" else "command_returned"; + Diagnostics.traceEdgeAction(popupContext(receipt)); + } + break :track returned; + }; dispatchReturnedCommand(parent, target, command, context, callback); } From 17375a236544ca3e7f46e56b811b3196c9d4052e Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 17:32:25 -0700 Subject: [PATCH 12/16] Record popup input observations without inferring command selection Gate diagnostic-only before/after/closure receipts behind both edge trace flags and prove accepted input plus popup cancellation does not establish a selected command. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit 98acbe44c445cd2f63d736723f288267b1732a73) --- .../ValidationRunner/Manifests/ui.json | 1 + Tools/windows/ValidationRunner/README.md | 15 +++++ .../Ui/Fixtures/PopupEffectFixture.cs | 12 +++- .../ValidationRunner/Ui/ManagedUiSuite.cs | 57 +++++++++++++++++ .../Ui/Production/UiaInputSafety.cs | 62 +++++++++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) diff --git a/Tools/windows/ValidationRunner/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index b05ea102..ca8c0a7d 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -1,6 +1,7 @@ { "units": [ { "id": "ui.navigation.headless-runner-plan", "owner": "Ui", "minimumCount": 14, "exactCount": 14, "requiredGroups": [] }, + { "id": "ui.popup.receipt.cancelled-after-queued-input", "owner": "Ui", "minimumCount": 1, "exactCount": 1, "requiredGroups": [] }, { "id": "ui.navigation.selection-event-controls", "owner": "Ui", "minimumCount": 12, "exactCount": 12, "requiredGroups": ["graph", "quick-chats", "project", "loop", "chat"] }, { "id": "ui.jump.workspace-destination-controls", "owner": "Ui", "minimumCount": 16, "exactCount": 16, "requiredGroups": [] }, diff --git a/Tools/windows/ValidationRunner/README.md b/Tools/windows/ValidationRunner/README.md index 5de1a9ab..b8db04cb 100644 --- a/Tools/windows/ValidationRunner/README.md +++ b/Tools/windows/ValidationRunner/README.md @@ -173,6 +173,21 @@ Overview, Quick Chats and other owners select neither of those rows. The without changing the independent roster, card, metadata, bounds or foreign-owner requirements. +With both process flags `GRAPHCODE_UIA_GATE=1` and +`GRAPHCODE_UIA_EDGE_ACTION_TRACE=1`, popup input emits +`UIA_POPUP_INPUT_RECEIPT` immediately before/after `SendInput` and when closure +is observed by the existing polling path. Each receipt records monotonic ticks +and their frequency, UTC, foreground handle/PID, cursor hit handle/PID, popup +visibility and expected/sent input counts. Observation errors are explicit. +These are read-only samples, not foreground leases or proof of command delivery: +`selectionOutcome` stays `unobserved`. The product's actual `TrackPopupMenu` +return and callback receipts establish selection or cancellation. +The `ui.managed` headless cancellation control models accepted input followed by +popup closure, foreign foreground/hit windows and an external command-zero +return. It also checks that disabling tracing preserves the original operation +counts. Tracing adds no input, activation, retries or sleeps, and is off unless +both flags match exactly. + UIA, packaging contracts, shell ownership, and managed/native tests consume those same implementations. The loader verifies the exact file/type, worktree, current source and compiler-reference fingerprints, and loaded module identity. diff --git a/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs b/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs index 0e592665..4239a637 100644 --- a/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs +++ b/Tools/windows/ValidationRunner/Ui/Fixtures/PopupEffectFixture.cs @@ -9,9 +9,11 @@ public sealed class PopupEffectFixture : IUiaInputOperations { public int MouseCalls, KeyCalls, CursorMoves, SelectItemCalls, MovePosts, MoveSends; public bool Visible; public int CursorX, CursorY, CursorReads, ForegroundReads; + public uint? ReturnedCommand; public void Reset(string scenario) { Scenario = scenario; MouseCalls = KeyCalls = CursorMoves = SelectItemCalls = MovePosts = MoveSends = 0; Visible = scenario != "hidden-popup"; CursorX = CursorY = CursorReads = ForegroundReads = 0; + ReturnedCommand = null; } @@ -43,7 +45,9 @@ public uint GetMenuState(IntPtr menu, uint position, uint flags) { } public void Sleep(uint milliseconds) { } public uint SendInput(uint count, Input[] inputs, int size) { - MouseCalls++; Visible = Scenario == "foreign-before-enter" || Scenario == "owned-enter"; return count; + MouseCalls++; Visible = Scenario == "foreign-before-enter" || Scenario == "owned-enter"; + if (Scenario == "cancel-after-queued-input") ReturnedCommand = 0; + return count; } public bool IsWindowVisible(IntPtr popup) { return Visible; } public bool PostMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam) { @@ -55,10 +59,14 @@ public IntPtr GetForegroundWindow() { if (Scenario == "unhilited-metadata-fault" && ForegroundReads == 2) throw new InvalidOperationException("Owned metadata fixture fault"); return Scenario == "foreign-foreground" || + (Scenario == "cancel-after-queued-input" && MouseCalls > 0) || (Scenario == "foreign-after-hover" && ForegroundReads == 2) || (Scenario == "foreign-before-enter" && ForegroundReads == 3) ? (IntPtr)99 : (IntPtr)42; } - public IntPtr WindowFromPoint(ScreenPoint point) { return Scenario == "underlying-shell-hit" ? (IntPtr)42 : (IntPtr)44; } + public IntPtr WindowFromPoint(ScreenPoint point) { + if (Scenario == "cancel-after-queued-input" && MouseCalls > 0) return (IntPtr)99; + return Scenario == "underlying-shell-hit" ? (IntPtr)42 : (IntPtr)44; + } public bool IsRetainedProcessAlive(uint processId) { return processId == 4242 && Scenario != "foreign-owner-all"; } public uint WindowProcessId(IntPtr window) { return Scenario == "foreign-owner-all" || window == (IntPtr)99 || diff --git a/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs b/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs index 3341853b..62cac81f 100644 --- a/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs +++ b/Tools/windows/ValidationRunner/Ui/ManagedUiSuite.cs @@ -100,6 +100,63 @@ private static IReadOnlyList CreateCases() foreach (string name in effects) cases.Add(new UiCase($"ui.popup.effect.{name}", () => RunPopupEffect(name), "ValidationRunner.Tests.ps1:1256-1328")); + cases.Add(new UiCase("ui.popup.receipt.cancelled-after-queued-input", () => + { + string? oldGate = Environment.GetEnvironmentVariable("GRAPHCODE_UIA_GATE"); + string? oldTrace = Environment.GetEnvironmentVariable("GRAPHCODE_UIA_EDGE_ACTION_TRACE"); + try + { + Environment.SetEnvironmentVariable("GRAPHCODE_UIA_GATE", "1"); + Environment.SetEnvironmentVariable("GRAPHCODE_UIA_EDGE_ACTION_TRACE", "1"); + var probe = new PopupEffectFixture(); + probe.Reset("cancel-after-queued-input"); + var input = new UiaInputSafety(probe); + var hit = input.ClickPopupMenuItem(new IntPtr(43), new IntPtr(42), 0, 5103, 4242); + var property = typeof(UiaInputSafety.PopupItemHit).GetProperty("InputReceipt"); + ActivationCases.Require(property is not null, + "RED: popup closure needs an input-only receipt, not inferred command selection"); + string json = System.Text.Json.JsonSerializer.Serialize(property!.GetValue(hit)); + using var document = System.Text.Json.JsonDocument.Parse(json); + var receipt = document.RootElement; + ActivationCases.Require(receipt.GetProperty("SelectionOutcome").GetString() == "unobserved", + "Queued input and closed popup must not claim command selection"); + var samples = receipt.GetProperty("Samples"); + ActivationCases.Require(samples.GetArrayLength() == 3 && + samples[0].GetProperty("Phase").GetString() == "before-send" && + samples[1].GetProperty("Phase").GetString() == "after-send" && + samples[2].GetProperty("Phase").GetString() == "popup-closed" && + samples[0].GetProperty("PopupVisible").GetBoolean() && + !samples[2].GetProperty("PopupVisible").GetBoolean() && + samples[1].GetProperty("Expected").GetUInt32() == 2 && + samples[1].GetProperty("Sent").GetUInt32() == 2 && + samples[0].GetProperty("ForegroundProcessId").GetUInt32() == 4242 && + samples[1].GetProperty("ForegroundProcessId").GetUInt32() == 7777 && + samples[0].GetProperty("HitProcessId").GetUInt32() == 4242 && + samples[1].GetProperty("HitProcessId").GetUInt32() == 7777 && + samples[0].GetProperty("CursorAvailable").GetBoolean() && + samples[0].GetProperty("ObservationError").ValueKind == System.Text.Json.JsonValueKind.Null && + samples[0].GetProperty("Timestamp").GetInt64() <= samples[1].GetProperty("Timestamp").GetInt64() && + samples[1].GetProperty("Timestamp").GetInt64() <= samples[2].GetProperty("Timestamp").GetInt64() && + probe.MouseCalls == 1 && probe.KeyCalls == 0, + "Popup receipt lost observation order/counts or added input"); + // A separately observed TrackPopupMenu return of zero is cancellation, + // even though this input receipt contains a highlight and closed popup. + ActivationCases.Require(probe.ReturnedCommand == 0 && probe.ReturnedCommand != (uint)hit.ItemId && + receipt.GetProperty("SelectionOutcome").GetString() == "unobserved", + "Command zero cannot be inferred as the requested command"); + Environment.SetEnvironmentVariable("GRAPHCODE_UIA_EDGE_ACTION_TRACE", null); + probe.Reset("owned"); + hit = input.ClickPopupMenuItem(new IntPtr(43), new IntPtr(42), 0, 5103, 4242); + ActivationCases.Require(property.GetValue(hit) is null && probe.MouseCalls == 1 && + probe.KeyCalls == 0 && probe.CursorReads == 3 && probe.ForegroundReads == 2, + "Disabled diagnostics changed the original native observation/input sequence"); + } + finally + { + Environment.SetEnvironmentVariable("GRAPHCODE_UIA_GATE", oldGate); + Environment.SetEnvironmentVariable("GRAPHCODE_UIA_EDGE_ACTION_TRACE", oldTrace); + } + }, "Actual shared popup helper; controlled queued-input/closure with external command-zero observation")); cases.Add(new UiCase("ui.geometry.seven-pixel-band", () => { diff --git a/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs b/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs index 5ab2e171..037ef558 100644 --- a/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs +++ b/Tools/windows/ValidationRunner/Ui/Production/UiaInputSafety.cs @@ -66,12 +66,35 @@ public struct KeyInputRecord { public uint Pad0, Pad1; } public sealed class PopupItemHit { + public PopupInputReceipt InputReceipt { get; set; } public int Position, ItemId, Left, Top, Right, Bottom; public int ScreenX, ScreenY, ClientX, ClientY, CursorBeforeX, CursorBeforeY; public int CursorAtX, CursorAtY; public bool Hilite, UsedKeyboardFallback, UsedSelectItemFallback; public bool HiliteAtEnterFallback, HiliteBeforeEnter; } +public sealed class PopupInputSample { + public string Phase { get; set; } + public long Timestamp { get; set; } + public string Utc { get; set; } + public long Foreground { get; set; } + public uint ForegroundProcessId { get; set; } + public bool CursorAvailable { get; set; } + public int CursorX { get; set; } + public int CursorY { get; set; } + public long HitWindow { get; set; } + public uint HitProcessId { get; set; } + public bool PopupVisible { get; set; } + public uint Expected { get; set; } + public uint Sent { get; set; } + public string ObservationError { get; set; } + } +public sealed class PopupInputReceipt { + public string SelectionOutcome => "unobserved"; + public long TimestampFrequency => System.Diagnostics.Stopwatch.Frequency; + public System.Collections.Generic.List Samples { get; } = + new System.Collections.Generic.List(); + } public uint LastEditClearExpected; public uint LastEditClearSent; public uint LastEditTextExpected; @@ -265,6 +288,37 @@ public PopupItemHit ClickPopupMenuItemStrict(IntPtr popup, IntPtr owner, int pos return hit; } private PopupItemHit ClickPopupMenuItemCore(IntPtr popup, IntPtr owner, int position, int commandId, uint expectedProcessId, bool allowFallback) { + PopupInputReceipt receipt = Environment.GetEnvironmentVariable("GRAPHCODE_UIA_GATE") == "1" && + Environment.GetEnvironmentVariable("GRAPHCODE_UIA_EDGE_ACTION_TRACE") == "1" + ? new PopupInputReceipt() : null; + void ObserveInput(string phase, uint expected, uint sent) { + if (receipt == null) return; + var sample = new PopupInputSample { + Phase = phase, Timestamp = System.Diagnostics.Stopwatch.GetTimestamp(), + Utc = DateTimeOffset.UtcNow.ToString("O"), Expected = expected, Sent = sent + }; + try { + IntPtr foreground = operations.GetForegroundWindow(); + sample.Foreground = foreground.ToInt64(); + sample.ForegroundProcessId = operations.WindowProcessId(foreground); + ScreenPoint cursor; + sample.CursorAvailable = operations.GetCursorPos(out cursor); + if (sample.CursorAvailable) { + sample.CursorX = cursor.X; sample.CursorY = cursor.Y; + IntPtr hit = operations.WindowFromPoint(cursor); + sample.HitWindow = hit.ToInt64(); + sample.HitProcessId = operations.WindowProcessId(hit); + } + sample.PopupVisible = operations.IsWindowVisible(popup); + } catch (Exception failure) { + sample.ObservationError = failure.ToString(); + } + receipt.Samples.Add(sample); + Console.WriteLine("UIA_POPUP_INPUT_RECEIPT=" + System.Text.Json.JsonSerializer.Serialize(new { + owner = owner.ToInt64(), popup = popup.ToInt64(), expectedProcessId, commandId, + selectionOutcome = receipt.SelectionOutcome, timestampFrequency = receipt.TimestampFrequency, sample + })); + } if (popup == IntPtr.Zero || position < 0) return null; IntPtr menu = operations.PopupMenuHandle(popup); RECT item; @@ -373,10 +427,14 @@ bool ReadMenuHilite() { operations.WindowProcessId(foregroundBeforeInput), operations.GetAncestor(foregroundBeforeInput, 2), operations.WindowProcessId(inputPointWindow), operations.GetAncestor(inputPointWindow, 2), operations.IsWindowVisible(popup))) throw new InvalidOperationException("Popup input ownership changed; no mouse input was injected"); + ObserveInput("before-send", (uint)inputs.Length, 0); uint sent = operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input))); + ObserveInput("after-send", (uint)inputs.Length, sent); if (sent != inputs.Length) throw new InvalidOperationException(String.Format("SendInput injected {0} of {1} popup mouse events: Win32Error={2}", sent, inputs.Length, Marshal.GetLastWin32Error())); for (int attempt = 0; attempt < 20 && operations.IsWindowVisible(popup); attempt++) operations.Sleep(10); + if (receipt != null && !operations.IsWindowVisible(popup)) + ObserveInput("popup-closed", (uint)inputs.Length, sent); bool keyboardFallback = false; bool hiliteAtEnterFallback = false; bool hiliteBeforeEnter = false; @@ -402,7 +460,11 @@ bool ReadMenuHilite() { if (!keyboardFallback) throw new InvalidOperationException(String.Format("Popup Enter fallback was rejected: Win32Error={0}", Marshal.GetLastWin32Error())); } + if (receipt != null && receipt.Samples[receipt.Samples.Count - 1].Phase != "popup-closed" && + !operations.IsWindowVisible(popup)) + ObserveInput("popup-closed", (uint)inputs.Length, sent); return new PopupItemHit { + InputReceipt = receipt, Position = position, ItemId = commandId, Left = item.Left, Top = item.Top, Right = item.Right, Bottom = item.Bottom, ScreenX = screenX, ScreenY = screenY, ClientX = point.X, ClientY = point.Y, From 749557030ed51ea59b55421bd3ea6384af1fc04b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 19:05:02 -0700 Subject: [PATCH 13/16] Preserve descendant ownership refusals when diagnostics fail Project bounded scalar refusal receipts, keep original in-memory error context, and expose secondary diagnostic faults without replacing ownership rejection. Cover fault stages, metadata compatibility and failed rehearsal data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit f06290f7c36eb8890edde4fee0786e7446432789) --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 90 ++++++++++++++++- Tools/windows/UiCaptureDriver.psm1 | 99 ++++++++++++++++--- Tools/windows/ValidationRunner/README.md | 11 +++ 3 files changed, 186 insertions(+), 14 deletions(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index ded72fdf..abd96643 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -1648,6 +1648,88 @@ function Invoke-CapDescendantLifetimeContract { } } +function Invoke-CapDescendantRefusalDiagnosticContract { + $ast = [Management.Automation.Language.Parser]::ParseFile($modulePath, [ref]$null, [ref]$null) + $owner = $ast.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Get-CapOwnedDescendants' + }, $false) + Assert-CapTest ($owner.Extent.Text.Contains('Write-CapDescendantRefusalDiagnostic -Failure $failure -Context $context -Refusal $refusal') -and + $owner.Extent.Text.Contains('throw $failure')) 'Actual descendant refusal must retain primary-preserving diagnostic dispatch.' + foreach ($name in @('Get-CapDescendantRefusalDiagnostic', 'Write-CapDescendantRefusalDiagnostic')) { + $definitions = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $false)) + Assert-CapTest ($definitions.Count -eq 1) "Missing unique primary-preserving diagnostic helper: $name" + . ([scriptblock]::Create($definitions[0].Extent.Text)) + } + $getterVisits = [Collections.Generic.List[string]]::new() + $graph = [pscustomobject]@{} + $graph | Add-Member ScriptProperty ForbiddenGraph { + $getterVisits.Add('visited') + throw 'Diagnostic traversed retained object graph' + } + $original = [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new('Held parent generation changed for PID 456; refusing.'), + 'HeldGeneration', [Management.Automation.ErrorCategory]::InvalidOperation, $graph) + $context = [pscustomobject]@{ + ObservedUtc = '2026-11-11T00:00:00Z'; ParentPid = 456; ParentCreationFileTimeUtc = 200L + RawRow = [pscustomobject]@{ ProcessId = 123; ParentProcessId = 456; SessionId = 0 + CreationDate = $null; ExecutablePath = ('x' * 5000); CommandLine = $graph } + NativeSnapshot = $graph; ParentNativeSnapshot = $graph; InitialNativeFailure = $original; ErrorRecord = $null + } + $diagnostic = Get-CapDescendantRefusalDiagnostic -Context $context -Refusal $original + $json = $diagnostic | ConvertTo-Json -Depth 6 -Compress + $decoded = $json | ConvertFrom-Json + Assert-CapTest ($getterVisits.Count -eq 0 -and $decoded.CimExecutablePath.Length -eq 2048 -and + $decoded.omittedGraphs -ccontains 'OriginalErrorRecord.TargetObject' -and + @($decoded.omittedFields | Where-Object field -CEQ 'CimCommandLine').Count -eq 1) ` + 'Refusal diagnostic must retain bounded scalars without traversing native/error graphs.' + $projection = (Get-Item Function:Get-CapDescendantRefusalDiagnostic).ScriptBlock + $faultCases = 0 + foreach ($stage in @('projection', 'serialization', 'write')) { + $primary = [InvalidOperationException]::new('DescendantIdentityMismatch: Held parent generation changed', $original.Exception) + $primary.Data['CapCaptureCode'] = 'DescendantIdentityMismatch' + $primary.Data['OriginalErrorRecord'] = $original + & { + param($Stage, $Primary, $Context, $Original, $Projection) + function Get-CapDescendantRefusalDiagnostic { + param($Context, $Refusal) + if ($Stage -ceq 'projection') { throw 'injected projection fault' } + & $Projection -Context $Context -Refusal $Refusal + } + function ConvertTo-Json { param($InputObject, $Depth, [switch]$Compress) + if ($Stage -ceq 'serialization') { throw 'injected serialization fault' } + return '{}' + } + function Write-CapTrace { param($Command, $Parameters) + if ($Stage -ceq 'write') { throw 'injected write fault' } + } + $caught = $null + $oldError = [Console]::Error + $diagnosticOutput = [IO.StringWriter]::new() + try { + [Console]::SetError($diagnosticOutput) + try { + Write-CapDescendantRefusalDiagnostic -Failure $Primary -Context $Context -Refusal $Original + throw $Primary + } catch { $caught = $_ } + } finally { [Console]::SetError($oldError) } + $reported = $diagnosticOutput.ToString() + $diagnosticOutput.Dispose() + Assert-CapTest ([object]::ReferenceEquals($caught.Exception, $Primary) -and + $caught.Exception.Data['CapCaptureCode'] -ceq 'DescendantIdentityMismatch' -and + [object]::ReferenceEquals($caught.Exception.Data['OriginalErrorRecord'], $Original) -and + $Primary.Data['DescendantDiagnosticFailure'].message -like "*injected $Stage fault*" -and + $reported.Contains('CAP_DESCENDANT_DIAGNOSTIC_FAILURE') -and + $reported.Contains("injected $Stage fault") -and $reported.Length -lt 2048) ` + "Diagnostic $Stage fault replaced the original held-generation refusal or hid its secondary failure." + } $stage $primary $context $original $projection + $faultCases++ + } + Assert-CapTest ($faultCases -eq 3) 'All diagnostic fault stages must execute.' + return @{ faultStages = $faultCases; getterVisits = $getterVisits.Count; boundedProjection = $true } +} + function Invoke-CapAdvisoryDiagnosticContract { $tokens=$null;$errors=$null $ast=[Management.Automation.Language.Parser]::ParseFile($modulePath,[ref]$tokens,[ref]$errors) @@ -1696,6 +1778,7 @@ function Invoke-CapAdvisoryDiagnosticContract { -not $diagnostic.PSObject.Properties['OriginalErrorRecord']) ` 'Bounded diagnostic lost useful scalar facts, explicit omissions/truncation, or original in-memory error.' return [pscustomobject]@{getterVisits=0;originalErrorRetained=$true;scalarDiagnostic=$diagnostic + refusalDiagnostic=(Invoke-CapDescendantRefusalDiagnosticContract) evidenceMode='SYNTHETIC_GETTER_QUALIFICATION_NOT_ORIGINAL_BLOCKED_STACK'} } @@ -6490,6 +6573,9 @@ function Invoke-CapPreflightReportContract { } }) } Assert-CapPreflightReport + $preflight.cases[0].passed = $false + $failedCaseRefusal = Assert-CapRejects { Assert-CapPreflightReport } 'nonempty successful exact-source offline rehearsal' + $preflight.cases[0].passed = $true $branches = [Collections.Generic.List[object]]::new() foreach ($behavior in @('normal','nonzero','timeout')) { $rows = @($preflight.cases | Where-Object name -CEQ "real zmx job lifecycle through actual command with target $behavior") @@ -6504,9 +6590,9 @@ function Invoke-CapPreflightReportContract { } $traces = @(Get-Content -LiteralPath (Join-Path $scratch 'test-calls.jsonl') | ForEach-Object { ConvertFrom-Json $_ }) $calls = @($traces | Where-Object command -CEQ 'Assert-CapPreflightReport') - Assert-CapTest ($branches.Count -eq 3 -and $calls.Count -eq 7) 'Actual preflight acceptance and all three behavior checks did not execute.' + Assert-CapTest ($branches.Count -eq 3 -and $calls.Count -eq 8) 'Actual preflight acceptance, failed-case refusal and all three behavior checks did not execute.' return @{ mode = 'SYNTHETIC_REPORT_DATA_NOT_EXECUTED_PROVIDER_OR_LIVE_PREFLIGHT' - validDataAccepted = $true; actualFunctionCalls = $calls.Count; behaviorBranches = @($branches.ToArray()) + validDataAccepted = $true; failedCaseRefusal = $failedCaseRefusal; actualFunctionCalls = $calls.Count; behaviorBranches = @($branches.ToArray()) syntheticCaseCount = $requiredNames.Count; requiredCaseNamesFromActualAst = $true functionSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData( [Text.Encoding]::UTF8.GetBytes($definition.Extent.Text))) } diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 5fe7863f..3459c42d 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -4112,6 +4112,91 @@ function Get-CapAdvisoryExclusionDiagnostic { return [pscustomobject]$facts } +function Get-CapDescendantRefusalDiagnostic { + param($Context, [Management.Automation.ErrorRecord]$Refusal) + $facts = [ordered]@{ + ObservedUtc = $Context.ObservedUtc; ParentPid = $Context.ParentPid + ParentCreationFileTimeUtc = $Context.ParentCreationFileTimeUtc + CimProcessId = $Context.RawRow.ProcessId; CimParentProcessId = $Context.RawRow.ParentProcessId + CimSessionId = $Context.RawRow.SessionId; CimCreationDate = $Context.RawRow.CreationDate + CimExecutablePath = $Context.RawRow.ExecutablePath; CimCommandLine = $Context.RawRow.CommandLine + errorType = $Refusal.Exception.GetType().FullName; message = $Refusal.Exception.Message + fullyQualifiedErrorId = $Refusal.FullyQualifiedErrorId; scriptStackTrace = $Refusal.ScriptStackTrace + } + foreach ($snapshotName in @('NativeSnapshot', 'ParentNativeSnapshot')) { + $snapshot = $Context.$snapshotName + foreach ($name in @('ProcessId', 'CreationFileTimeUtc', 'ExitFileTimeUtc', 'ImagePath', 'IsExited')) { + $property = if ($null -ne $snapshot) { $snapshot.PSObject.Properties[$name] } else { $null } + $facts["${snapshotName}_$name"] = if ($null -ne $property) { $property.Value } else { $null } + } + } + $truncated = [Collections.Generic.List[object]]::new() + $omitted = [Collections.Generic.List[object]]::new() + foreach ($name in @($facts.Keys)) { + $value = $facts[$name] + if ($value -is [DateTime] -or $value -is [DateTimeOffset]) { $facts[$name] = $value.ToString('o') } + elseif ($value -is [string]) { + if ($value.Length -gt 2048) { + $facts[$name] = $value.Substring(0, 2048) + $truncated.Add([pscustomobject]@{ field = $name; originalCharacters = $value.Length; retainedCharacters = 2048 }) + } + } elseif ($null -ne $value -and $value -isnot [int] -and $value -isnot [long] -and + $value -isnot [uint32] -and $value -isnot [uint64] -and $value -isnot [bool]) { + $omitted.Add([pscustomobject]@{ field = $name; reason = 'NON_SCALAR_VALUE_NOT_TRAVERSED'; type = $value.GetType().FullName }) + $facts[$name] = $null + } + } + $facts.diagnosticCompleteness = 'BOUNDED_SCALARS_WITH_EXPLICIT_GRAPH_OMISSIONS' + $facts.RawRow = [pscustomobject]@{ + ProcessId = $facts.CimProcessId; ParentProcessId = $facts.CimParentProcessId + SessionId = $facts.CimSessionId; CreationDate = $facts.CimCreationDate + ExecutablePath = $facts.CimExecutablePath; CommandLine = $facts.CimCommandLine + } + $facts.ErrorRecord = [pscustomobject]@{ + Type = $facts.errorType; Message = $facts.message; FullyQualifiedErrorId = $facts.fullyQualifiedErrorId + ScriptStackTrace = $facts.scriptStackTrace + } + foreach ($snapshotName in @('NativeSnapshot', 'ParentNativeSnapshot')) { + $record = [ordered]@{} + foreach ($name in @('ProcessId', 'CreationFileTimeUtc', 'ExitFileTimeUtc', 'ImagePath', 'IsExited')) { + $record[$name] = $facts["${snapshotName}_$name"] + $facts.Remove("${snapshotName}_$name") + } + $facts[$snapshotName] = if ($null -eq $Context.$snapshotName) { $null } else { [pscustomobject]$record } + } + $facts.omittedGraphs = @('OriginalErrorRecord', 'OriginalErrorRecord.TargetObject', + 'OriginalErrorRecord.InvocationInfo', 'OriginalErrorRecord.Exception.Data', + 'NativeSnapshot.NonScalarMembers', 'ParentNativeSnapshot.NonScalarMembers', 'InitialNativeFailure', 'Module', 'Process') + $facts.omittedFields = @($omitted.ToArray()) + $facts.truncatedFields = @($truncated.ToArray()) + return [pscustomobject]$facts +} + +function Write-CapDescendantRefusalDiagnostic { + param([Exception]$Failure, $Context, [Management.Automation.ErrorRecord]$Refusal) + try { + $Context.ErrorRecord = [pscustomobject]@{ + Type = $Refusal.Exception.GetType().FullName; Message = $Refusal.Exception.Message + Raw = $Refusal.Exception.ToString(); FullyQualifiedErrorId = $Refusal.FullyQualifiedErrorId + Category = [string]$Refusal.CategoryInfo; ScriptStackTrace = $Refusal.ScriptStackTrace + ObservedUtc = [DateTimeOffset]::UtcNow.ToString('o') + } + $diagnostic = Get-CapDescendantRefusalDiagnostic -Context $Context -Refusal $Refusal + $json = ConvertTo-Json -InputObject $diagnostic -Depth 6 -Compress + Write-CapTrace -Command 'CapDescendantRefusal' -Parameters @{ diagnosticJson = $json } + } catch { + $secondary = [pscustomobject]@{ + type = $_.Exception.GetType().FullName + message = $_.Exception.Message.Substring(0, [Math]::Min(1024, $_.Exception.Message.Length)) + disposition = 'PRIMARY_OWNERSHIP_REFUSAL_PRESERVED' + } + $Failure.Data['DescendantDiagnosticFailure'] = $secondary + try { + [Console]::Error.WriteLine("CAP_DESCENDANT_DIAGNOSTIC_FAILURE type=$($secondary.type) message=$($secondary.message); primary ownership refusal preserved") + } catch { $Failure.Data['DescendantDiagnosticReportingFailure'] = $_.Exception } + } +} + function Get-CapOwnedDescendants { [CmdletBinding()] param( @@ -4345,23 +4430,13 @@ function Get-CapOwnedDescendants { $retained = $true $changed = $true } catch { - $context.ErrorRecord = [pscustomobject]@{ - Type = $_.Exception.GetType().FullName - Message = $_.Exception.Message - Raw = $_.Exception.ToString() - FullyQualifiedErrorId = $_.FullyQualifiedErrorId - Category = [string]$_.CategoryInfo - ScriptStackTrace = $_.ScriptStackTrace - ObservedUtc = [DateTimeOffset]::UtcNow.ToString('o') - } + $refusal = $_ $failure = [InvalidOperationException]::new("${failureCode}: $($_.Exception.Message)", $_.Exception) $failure.Data['CapCaptureCode'] = $failureCode $failure.Data['CandidateContext'] = $context $failure.Data['OriginalErrorRecord'] = $_ $failure.Data['RawCimRow'] = $row - Write-CapTrace -Command 'CapDescendantRefusal' -Parameters @{ - diagnosticJson = ($context | ConvertTo-Json -Depth 10 -Compress) - } + Write-CapDescendantRefusalDiagnostic -Failure $failure -Context $context -Refusal $refusal throw $failure } finally { if (-not $retained -and $null -ne $process) { $process.Dispose() } diff --git a/Tools/windows/ValidationRunner/README.md b/Tools/windows/ValidationRunner/README.md index b8db04cb..6474b5aa 100644 --- a/Tools/windows/ValidationRunner/README.md +++ b/Tools/windows/ValidationRunner/README.md @@ -200,6 +200,17 @@ artifacts; root console summaries do not forward arbitrary child text. The capture suite alone admits its exact path-bound, deliberately injected negative-control diagnostics, while still requiring all 182 cases and the 14 named groups. +Descendant-refusal diagnostics project only bounded scalar facts, preserving +the raw context and original error in memory. Projection, JSON serialization +or trace-write faults attach an explicit bounded secondary diagnostic and must +not replace the ownership refusal. Focused +`UiCaptureDriver.Tests.ps1 -AdvisoryDiagnosticContractOnly` covers all three fault +stages and excludes retained-object graph traversal; `-AppChildContractOnly` +checks the real held-generation refusal with prepared harmless fixtures. +`-DescendantMetadataContractOnly` preserves the scalar trace schema, and +`-PreflightReportContractOnly` verifies that successful report data is accepted +while a failed case is still rejected. These are no-desktop checks, not a full +capture-suite or native UI acceptance result. Each host-supervised child starts suspended in a held, non-breakaway job before it can execute. Original monotonic deadlines, independent stream EOF, primary errors, live From d2dfdadf80bd430a84590c38432f3b4432d164ca Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 20:08:14 -0700 Subject: [PATCH 14/16] Admit exact diagnostic closure receipts in the popup source oracle Preserve the no-receipt positive case and all hilite/ownership/forwarding controls; reject six malformed or input-emitting receipt boundaries without widening the safety regex. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit 95346104dc025ea597e4694cc24144a07837fc29) --- .../ValidationRunner/Ui/UiSourceContracts.ps1 | 28 +++++++++++++++++-- .../Ui/source-assertions.json | 2 +- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 index 9c68eb75..9269bb2e 100644 --- a/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 @@ -163,6 +163,28 @@ foreach ($case in $popupCases) { if ($case.name -ceq 'actual') { Assert-UiContract (Confirm-RootPopupSource $inputSource) ` 'Declarative popup oracle rejects the actual checked hilite reader' + $closureObservation = " if (receipt != null && !operations.IsWindowVisible(popup))`n" + + ' ObserveInput("popup-closed", (uint)inputs.Length, sent);' + Assert-UiContract ([regex]::Matches($inputSource, [regex]::Escape($closureObservation)).Count -eq 1) ` + 'Receipt boundary positive control must identify exactly one production closure observation' + Assert-UiContract (Confirm-RootPopupSource $inputSource.Replace($closureObservation, '')) ` + 'Declarative popup oracle must preserve the no-receipt positive case' + $receiptMutations = @( + ' if (!operations.IsWindowVisible(popup))' + "`n" + ' ObserveInput("popup-closed", (uint)inputs.Length, sent);' + ' if (receipt != null)' + "`n" + ' ObserveInput("popup-closed", (uint)inputs.Length, sent);' + $closureObservation.Replace('"popup-closed"', '"selected"') + $closureObservation.Replace('inputs.Length, sent', 'inputs.Length, 0') + $closureObservation + "`n" + ' operations.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));' + $closureObservation + "`n" + ' operations.PostMessage(popup, 0x0100, (UIntPtr)0x0D, IntPtr.Zero);' + ) + Assert-UiContract ($receiptMutations.Count -eq 6 -and + @($receiptMutations | Sort-Object -Unique).Count -eq 6) ` + 'Receipt boundary controls must exercise six distinct negative source mutations' + foreach ($replacement in $receiptMutations) { + Assert-UiContract ($replacement -cne $closureObservation) 'Receipt boundary mutation did not change production source' + Assert-UiContract (-not (Confirm-RootPopupSource $inputSource.Replace($closureObservation, $replacement))) ` + 'Declarative popup oracle accepted an ungated, misreported or input-emitting closure boundary' + } $rootMutations = @( @{ from = 'bool ReadMenuHilite()'; to = 'bool UnboundMenuHilite()' }, @{ from = 'rawMenuState = operations.GetMenuState(menu, (uint)position, 0x0400);'; to = 'rawMenuState = 0;' }, @@ -192,8 +214,8 @@ foreach ($case in $popupCases) { Replace('bool UncheckedMenuHilite()', "$helper`n bool UncheckedMenuHilite()") Assert-UiContract (-not (Confirm-RootPopupSource $unusedDecoy)) ` 'Declarative popup oracle accepted an unused checked helper while callers used an unchecked reader' - Assert-UiContract ($rootPopupResults.Count -eq $rootMutations.Count + 2 -and - @($rootPopupResults | Where-Object { $_ }).Count -eq 1) ` + Assert-UiContract ($rootPopupResults.Count -eq $rootMutations.Count + $receiptMutations.Count + 3 -and + @($rootPopupResults | Where-Object { $_ }).Count -eq 2) ` 'Declarative popup positive and each source mutation did not execute exactly once' foreach ($mutation in @( @{ from = 'return ClickPopupMenuItemCore(popup, owner, position, commandId, expectedProcessId, true);'; to = 'return null;' }, @@ -210,7 +232,7 @@ foreach ($case in $popupCases) { } } foreach ($result in $results | Where-Object { $_.id -ceq 'ui.popup.source.actual' -and $_.status -ceq 'Passed' }) { - $result.evidence += "; actual declarative predicate/evaluator checks=$($rootPopupResults.Count), accepted=1, refused=$($rootPopupResults.Count - 1)" + $result.evidence += "; actual declarative predicate/evaluator checks=$($rootPopupResults.Count), accepted=2, refused=$($rootPopupResults.Count - 2)" } Assert-UiContract ($activationCases.Count -eq 6 -and $popupCases.Count -eq 9 -and $results.Count -eq 15) ` 'Source guard case count changed' diff --git a/Tools/windows/ValidationRunner/Ui/source-assertions.json b/Tools/windows/ValidationRunner/Ui/source-assertions.json index 94ae8e2a..74c00a57 100644 --- a/Tools/windows/ValidationRunner/Ui/source-assertions.json +++ b/Tools/windows/ValidationRunner/Ui/source-assertions.json @@ -3406,7 +3406,7 @@ "left": { "right": { "kind": "literal", - "value": "(?s)private PopupItemHit ClickPopupMenuItemCore\\([^)]*bool allowFallback\\)\\s*\\{(?:(?!operations\\.PostMessage\\(popup, 0x0100).)*?bool ReadMenuHilite\\(\\)\\s*\\{\\s*rawMenuState = operations\\.GetMenuState\\(menu, \\(uint\\)position, 0x0400\\);\\s*menuStateObserved = true;\\s*if \\(rawMenuState == UInt32\\.MaxValue\\)\\s*throw PopupStateFailure\\(\"Popup menu state unavailable; no further input was injected\"\\);\\s*return \\(rawMenuState & 0x0080\\) != 0;\\s*\\}(?:(?!operations\\.PostMessage\\(popup, 0x0100).)*?for \\(int attempt = 0; attempt < 20 && operations\\.IsWindowVisible\\(popup\\); attempt\\+\\+\\) operations\\.Sleep\\(10\\);\\s*bool keyboardFallback = false;\\s*bool hiliteAtEnterFallback = false;\\s*bool hiliteBeforeEnter = false;\\s*if \\(operations\\.IsWindowVisible\\(popup\\)\\)\\s*\\{\\s*if \\(!allowFallback\\)\\s*throw new InvalidOperationException\\(\"Strict popup remained open after pointer input; no selection or keyboard fallback was emitted\"\\);(?:(?!operations\\.PostMessage\\(popup, 0x0100).)*?hiliteAtEnterFallback = ReadMenuHilite\\(\\);\\s*operations\\.SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\);\\s*hiliteBeforeEnter = ReadMenuHilite\\(\\);\\s*\\}\\s*if \\(operations\\.IsWindowVisible\\(popup\\)\\)\\s*\\{\\s*if \\(!hiliteBeforeEnter\\)\\s*throw new InvalidOperationException\\(String\\.Format\\(\"Popup item \\{0\\} was not hilited before the Enter fallback \\(hilite at fallback=\\{1\\}\\)\", commandId, hiliteAtEnterFallback\\)\\);\\s*RequireOwnedPopupForeground\\(popup, owner, expectedProcessId\\);\\s*keyboardFallback =\\s*operations\\.PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" + "value": "(?s)private PopupItemHit ClickPopupMenuItemCore\\([^)]*bool allowFallback\\)\\s*\\{(?:(?!operations\\.PostMessage\\(popup, 0x0100).)*?bool ReadMenuHilite\\(\\)\\s*\\{\\s*rawMenuState = operations\\.GetMenuState\\(menu, \\(uint\\)position, 0x0400\\);\\s*menuStateObserved = true;\\s*if \\(rawMenuState == UInt32\\.MaxValue\\)\\s*throw PopupStateFailure\\(\"Popup menu state unavailable; no further input was injected\"\\);\\s*return \\(rawMenuState & 0x0080\\) != 0;\\s*\\}(?:(?!operations\\.PostMessage\\(popup, 0x0100).)*?for \\(int attempt = 0; attempt < 20 && operations\\.IsWindowVisible\\(popup\\); attempt\\+\\+\\) operations\\.Sleep\\(10\\);\\s*(?:if \\(receipt != null && !operations\\.IsWindowVisible\\(popup\\)\\)\\s*ObserveInput\\(\"popup-closed\", \\(uint\\)inputs\\.Length, sent\\);\\s*)?bool keyboardFallback = false;\\s*bool hiliteAtEnterFallback = false;\\s*bool hiliteBeforeEnter = false;\\s*if \\(operations\\.IsWindowVisible\\(popup\\)\\)\\s*\\{\\s*if \\(!allowFallback\\)\\s*throw new InvalidOperationException\\(\"Strict popup remained open after pointer input; no selection or keyboard fallback was emitted\"\\);(?:(?!operations\\.PostMessage\\(popup, 0x0100).)*?hiliteAtEnterFallback = ReadMenuHilite\\(\\);\\s*operations\\.SendMessage\\(popup, 0x01E5, \\(UIntPtr\\)position, IntPtr\\.Zero\\);\\s*hiliteBeforeEnter = ReadMenuHilite\\(\\);\\s*\\}\\s*if \\(operations\\.IsWindowVisible\\(popup\\)\\)\\s*\\{\\s*if \\(!hiliteBeforeEnter\\)\\s*throw new InvalidOperationException\\(String\\.Format\\(\"Popup item \\{0\\} was not hilited before the Enter fallback \\(hilite at fallback=\\{1\\}\\)\", commandId, hiliteAtEnterFallback\\)\\);\\s*RequireOwnedPopupForeground\\(popup, owner, expectedProcessId\\);\\s*keyboardFallback =\\s*operations\\.PostMessage\\(popup, 0x0100, \\(UIntPtr\\)0x0D, IntPtr\\.Zero\\)" }, "left": { "kind": "resource", From 2428b5ae9ff52415508ecb245ca18731c1e37818 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 20:32:41 -0700 Subject: [PATCH 15/16] Emit timeout fixture output before nested PowerShell startup Keep the original one-second deadline and collision/cleanup assertions while writing partial stdout and stderr from the existing mock cmd host; assert generated marker ordering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens (cherry picked from commit bdd8cb5659758eaa382116ce302c8522c896d5ed) --- ...cessibilityProvider.Native.Tests.Harness.Tests.ps1 | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 index d84077f5..577f61c5 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.Harness.Tests.ps1 @@ -95,10 +95,19 @@ function Test-AbruptStage([string] $Stage, [switch] $RepeatedPath, [switch] $Tim $toolBody = "@echo off`r`nexit 255`r`n" if ($TimeoutMode) { $extra = if ($MetadataFailure) { 'mkdir native\supervision.json' + "`r`n" } else { "" } - $toolBody = "@echo off`r`n$extra`"$(Join-Path $PSHOME 'pwsh.exe')`" -NoProfile -Command `"[Console]::WriteLine('timeout-ready'); [Console]::Error.WriteLine('timeout-stderr'); Start-Sleep -Seconds 30`"`r`n" + $toolBody = "@echo off`r`n${extra}echo timeout-ready`r`necho timeout-stderr 1>&2`r`n`"$(Join-Path $PSHOME 'pwsh.exe')`" -NoProfile -Command `"Start-Sleep -Seconds 30`"`r`n" } if ($FakeSummary) { $toolBody = "@echo off`r`necho Native accessibility provider: 280 checks, 0 failures`r`nexit /b 0`r`n" } [IO.File]::WriteAllText((Join-Path $fixture $tool), $toolBody, [Text.Encoding]::ASCII) + if ($TimeoutMode) { + $recipe = [IO.File]::ReadAllText((Join-Path $fixture $tool)) + $stdoutMarker = $recipe.IndexOf("echo timeout-ready`r`n", [StringComparison]::Ordinal) + $stderrMarker = $recipe.IndexOf("echo timeout-stderr 1>&2`r`n", [StringComparison]::Ordinal) + $sleeper = $recipe.IndexOf('"' + (Join-Path $PSHOME 'pwsh.exe') + '"', [StringComparison]::Ordinal) + if ($stdoutMarker -lt 0 -or $stderrMarker -le $stdoutMarker -or $sleeper -le $stderrMarker) { + throw "Timeout fixture must emit both exact partial-stream markers before nested sleeper startup" + } + } } $wrapper = Join-Path $fixture "invoke.ps1" [IO.File]::WriteAllText($wrapper, @' From 0764a45c8c7b3453206c975121883509fc468216 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 10 Nov 2026 23:45:01 -0700 Subject: [PATCH 16/16] Normalize popup source contracts across checkout line endings Normalize CRLF to LF before exact closure matching and semantic mutations. Exercise LF and CRLF actual-source controls without weakening the existing ownership, hilite, receipt or forwarding refusals. RED: source contract 15 executed, 14 passed, 1 failed on the new CRLF closure assertion. GREEN: 15/15 source contracts, LF and CRLF checkout fixtures each 15/15. REGRESSION: combined source/root-source/managed 129/129; full unfiltered runner 693/693 complete and positive. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../ValidationRunner/Ui/UiSourceContracts.ps1 | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 index 9269bb2e..0d0f5312 100644 --- a/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 +++ b/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 @@ -4,10 +4,13 @@ param( [Parameter(Mandatory)][string] $AssemblyPath ) $ErrorActionPreference = 'Stop' +function ConvertTo-SourceLineEndings([string] $Source) { + return $Source.Replace("`r`n", "`n") +} $uiRoot = Join-Path $RepositoryRoot 'Tools\windows\ValidationRunner\Ui' $activation = [IO.File]::ReadAllText((Join-Path $RepositoryRoot 'Tools\windows\UiaWindowActivation.cs')) $gate = [IO.File]::ReadAllText((Join-Path $uiRoot 'Production\GraphCodeUiaGateState.cs')) -$inputSource = [IO.File]::ReadAllText((Join-Path $uiRoot 'Production\UiaInputSafety.cs')) +$inputSource = ConvertTo-SourceLineEndings ([IO.File]::ReadAllText((Join-Path $uiRoot 'Production\UiaInputSafety.cs'))) $results = [Collections.Generic.List[object]]::new() function Assert-UiContract([bool] $Condition, [string] $Message) { @@ -165,6 +168,14 @@ foreach ($case in $popupCases) { 'Declarative popup oracle rejects the actual checked hilite reader' $closureObservation = " if (receipt != null && !operations.IsWindowVisible(popup))`n" + ' ObserveInput("popup-closed", (uint)inputs.Length, sent);' + $lfSource = $inputSource.Replace("`r`n", "`n") + foreach ($checkoutSource in @($lfSource, $lfSource.Replace("`n", "`r`n"))) { + $normalizedSource = ConvertTo-SourceLineEndings $checkoutSource + Assert-UiContract ([regex]::Matches($normalizedSource, [regex]::Escape($closureObservation)).Count -eq 1) ` + 'LF and CRLF checkouts must identify exactly one production closure observation' + Assert-UiContract ((Confirm-PopupSource $normalizedSource) -and (Confirm-RootPopupSource $normalizedSource)) ` + 'LF and CRLF checkouts must accept the actual checked popup source' + } Assert-UiContract ([regex]::Matches($inputSource, [regex]::Escape($closureObservation)).Count -eq 1) ` 'Receipt boundary positive control must identify exactly one production closure observation' Assert-UiContract (Confirm-RootPopupSource $inputSource.Replace($closureObservation, '')) ` @@ -214,8 +225,8 @@ foreach ($case in $popupCases) { Replace('bool UncheckedMenuHilite()', "$helper`n bool UncheckedMenuHilite()") Assert-UiContract (-not (Confirm-RootPopupSource $unusedDecoy)) ` 'Declarative popup oracle accepted an unused checked helper while callers used an unchecked reader' - Assert-UiContract ($rootPopupResults.Count -eq $rootMutations.Count + $receiptMutations.Count + 3 -and - @($rootPopupResults | Where-Object { $_ }).Count -eq 2) ` + Assert-UiContract ($rootPopupResults.Count -eq $rootMutations.Count + $receiptMutations.Count + 5 -and + @($rootPopupResults | Where-Object { $_ }).Count -eq 4) ` 'Declarative popup positive and each source mutation did not execute exactly once' foreach ($mutation in @( @{ from = 'return ClickPopupMenuItemCore(popup, owner, position, commandId, expectedProcessId, true);'; to = 'return null;' }, @@ -232,7 +243,7 @@ foreach ($case in $popupCases) { } } foreach ($result in $results | Where-Object { $_.id -ceq 'ui.popup.source.actual' -and $_.status -ceq 'Passed' }) { - $result.evidence += "; actual declarative predicate/evaluator checks=$($rootPopupResults.Count), accepted=2, refused=$($rootPopupResults.Count - 2)" + $result.evidence += "; actual declarative predicate/evaluator checks=$($rootPopupResults.Count), accepted=4, refused=$($rootPopupResults.Count - 4); LF and CRLF checkout controls=2" } Assert-UiContract ($activationCases.Count -eq 6 -and $popupCases.Count -eq 9 -and $results.Count -eq 15) ` 'Source guard case count changed'