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, @' diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index 4df18028..44aaa016 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -8,6 +8,441 @@ #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 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) { + 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 ClientToScreen selectionClientToScreen +#define UiaRaiseAutomationEvent selectionAutomationEvent +#define UiaRaiseAutomationPropertyChangedEvent selectionPropertyEvent +#define UiaRaiseStructureChangedEvent selectionStructureEvent +#define UiaHostProviderFromHwnd selectionHostProvider +#define UiaReturnRawElementProvider selectionReturnProvider +#include "..\..\..\graphcode-windows\src\AccessibilityProvider.cpp" +#undef PostMessageW +#undef ClientToScreen +#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}; + 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, bounds, 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(); + 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(); + } + + 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 +451,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 +489,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 +576,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 +677,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 +740,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 +875,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 +951,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 +986,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 +1040,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 +1094,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 +1152,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 +1186,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 +1441,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/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 b/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 new file mode 100644 index 00000000..9fb38f73 --- /dev/null +++ b/Tools/windows/Tests/NavigationSelectionOracle.Tests.ps1 @@ -0,0 +1,237 @@ +[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 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) +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 }) +$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") +[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)) +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/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/Manifests/ui.json b/Tools/windows/ValidationRunner/Manifests/ui.json index e297d171..ca8c0a7d 100644 --- a/Tools/windows/ValidationRunner/Manifests/ui.json +++ b/Tools/windows/ValidationRunner/Manifests/ui.json @@ -1,5 +1,9 @@ { "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": [] }, { "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": [] }, @@ -32,6 +36,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/README.md b/Tools/windows/ValidationRunner/README.md index d0420c8b..6474b5aa 100644 --- a/Tools/windows/ValidationRunner/README.md +++ b/Tools/windows/ValidationRunner/README.md @@ -155,6 +155,39 @@ 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. +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. + +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. @@ -167,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 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/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/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/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, diff --git a/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs b/Tools/windows/ValidationRunner/Ui/SuiteRegistration.cs index eaf7b41c..0a96016b 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,13 @@ public static class SuiteRegistration { public static IReadOnlyList CreateSuites() => new IValidationSuite[] { - new ActivationSuite(), new ManagedUiSuite(), new CardsAcceptanceControlsSuite(), new NativeUiSuite(), new EditBufferSuite(), new ControlClickTopologySuite() + new ActivationSuite(), new ManagedUiSuite(), new CardsAcceptanceControlsSuite(), new NativeUiSuite(), new EditBufferSuite(), 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" }), + new StandaloneSuites.CaseScriptSuite("ui.navigation-oracle-contracts", + @"Tools\windows\Tests\NavigationSelectionOracle.Tests.ps1", + "UI_CASE_RESULT=", new[] { "ui.navigation.selection-snapshot-controls", "ui.navigation.selection-event-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/ValidationRunner/Ui/UiSourceContracts.ps1 b/Tools/windows/ValidationRunner/Ui/UiSourceContracts.ps1 index 9c68eb75..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) { @@ -163,6 +166,36 @@ 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);' + $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, '')) ` + '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 +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 + 2 -and - @($rootPopupResults | Where-Object { $_ }).Count -eq 1) ` + 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;' }, @@ -210,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=1, refused=$($rootPopupResults.Count - 1)" + $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' 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", diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 0e741034..037a56c4 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2005,6 +2005,147 @@ 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 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, @@ -3800,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) { @@ -6547,7 +6689,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 }) @@ -6771,7 +6913,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) @@ -7556,12 +7698,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" @@ -7845,13 +7989,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() @@ -7933,6 +8079,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() @@ -7955,8 +8102,13 @@ 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 + 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 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 5f3ecce9..e7995072 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -29,6 +29,9 @@ 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`) | +| 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/graphcode-windows/README.md b/graphcode-windows/README.md index e5734297..5dc6cf5e 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -1,5 +1,23 @@ # 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. +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. +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/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..d716143c 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) { @@ -551,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); @@ -563,19 +591,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 +610,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 +652,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 +672,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 +684,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 +863,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 +884,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 +900,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 +916,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 +944,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 +958,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 +984,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 +997,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 +1018,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 +1044,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 +1124,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 +1196,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 +1447,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 +1508,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 +1613,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) { diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 97373d3d..426f500f 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), @@ -9268,7 +9318,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( @@ -9343,7 +9393,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; }, } } @@ -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) @@ -10664,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 @@ -10979,7 +11082,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) { @@ -13838,6 +13941,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); @@ -13879,6 +14065,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(); @@ -13922,18 +14113,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; @@ -13941,7 +14137,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)); } } } @@ -14336,6 +14532,7 @@ fn paintedIngressFooter(app: *App, sidebar_bottom: i32, update: bool) !?c.RECT { hdc, &app.model, app.surface, + app.workspaceQuickChatID(), app.selection_active, app.currentWorktreeInspection(), "", @@ -14702,6 +14899,7 @@ fn paintSidebarBitmap(app: *App, viewport_bottom: i32, update: bool, scroll: i32 dc, &app.model, app.surface, + app.workspaceQuickChatID(), app.selection_active, app.currentWorktreeInspection(), "", diff --git a/graphcode-windows/src/Diagnostics.zig b/graphcode-windows/src/Diagnostics.zig index bdcb5508..976a4c48 100644 --- a/graphcode-windows/src/Diagnostics.zig +++ b/graphcode-windows/src/Diagnostics.zig @@ -3,6 +3,182 @@ 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, + popup_message, + 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", + 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, + + 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} 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.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, + }); + } +}; + +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; + } + } + if (!edgeActionTraceEnabled()) return; + mutex.lock(); + defer mutex.unlock(); + 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; + }; + 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) + 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)); +} + +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/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/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/GraphContextMenu.zig b/graphcode-windows/src/GraphContextMenu.zig index 74c76fd8..4f8e29c1 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 }; @@ -312,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, @@ -320,19 +356,110 @@ 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, - 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); +} + +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 { 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; diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 957ba701..09a82613 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -689,7 +689,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 |