// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package atspi import ( "context" "errors" "fmt" "os" "os/exec" "strings" "testing" "time" ) func TestNativeGUITablePresentation(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_GUI_TABLE") != "1" { t.Skip("requires isolated GTK table fixture") } ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() tree, err := adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } window := findNamedReference(t, tree, "Table presentation probe") if !window.Valid() { t.Fatal("GTK table window is absent from AT-SPI desktop") } frame, err := tree.Resolve(window) if err != nil || frame.Description != "Select an item and press Enter to open it." { t.Fatalf("GTK frame description = %q, %v", frame.Description, err) } if err := exec.Command("xdotool", "key", "Down").Run(); err != nil { t.Fatal(err) } selectedRow: for { select { case <-ctx.Done(): t.Fatal("GTK table selection did not expose row context") case event := <-adapter.Events(): if event.Type != SelectionChangedEvent || event.Source.Name != "Beta" { continue } if event.Source.TableName != "Probe items" || event.Source.TableRowCount != 2 || event.Source.TableColumnCount != 2 || event.Source.TableRowText != "Beta, Shortcut" || !event.Source.GUITable || !event.Source.TablePositionKnown || event.Source.TableRowIndex != 1 || event.Source.TableColumnIndex != 0 || event.Source.TablePath == "" || event.WindowDescription != "Select an item and press Enter to open it." { t.Fatalf("GTK selected row context = %#v", event.Source) } break selectedRow } } if err := exec.Command("xdotool", "key", "Right").Run(); err != nil { t.Fatal(err) } for { select { case <-ctx.Done(): t.Fatal("GTK table did not expose horizontal cell movement") case event := <-adapter.Events(): if event.Type != ActiveDescendantEvent || event.Source.Name != "Shortcut" { continue } if !event.Source.TablePositionKnown || event.Source.TableRowIndex != 1 || event.Source.TableColumnIndex != 1 { t.Fatalf("GTK horizontal cell context = %#v", event.Source) } return } } } func TestNativeGUITableFocusPresentsSelectedChild(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_GUI_TABLE") != "1" { t.Skip("requires isolated GTK table fixture") } ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() tree, err := adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } window := findNamedReference(t, tree, "Table presentation probe") if !window.Valid() { t.Fatal("GTK table window is absent from AT-SPI desktop") } windowIDs, err := exec.Command("xdotool", "search", "--name", "Table presentation probe").Output() if err != nil || len(strings.Fields(string(windowIDs))) == 0 { t.Fatalf("GTK table window not found: %v", err) } if err := exec.Command("xdotool", "windowactivate", strings.Fields(string(windowIDs))[0]).Run(); err != nil { t.Fatal(err) } for { select { case <-ctx.Done(): t.Fatal("GTK table focus did not present selected child") case event := <-adapter.Events(): if event.Type != FocusEvent && event.Type != FocusedStateEvent { continue } if event.Source.Role != "table cell" || event.Source.Name != "Alpha" { t.Fatalf("focused table source = %#v", event.Source) } if event.Source.TableName != "Probe items" || !event.Source.GUITable || !event.Source.TablePositionKnown || event.Source.TablePath == "" { t.Fatalf("focused table context = %#v", event.Source) } return } } } func TestNativeGUITableMultiselectionFocus(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_GUI_TABLE_MULTI") != "1" { t.Skip("requires isolated multiselection GTK table fixture") } ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() tree, err := adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } window := findNamedReference(t, tree, "Table presentation probe") if !window.Valid() { t.Fatal("GTK table window is absent from AT-SPI desktop") } for _, name := range []string{"Alpha", "Beta"} { cell := findNamedReference(t, tree, name) if !cell.Valid() { t.Fatalf("%s cell is absent from the multiselection table", name) } snapshot, err := tree.Resolve(cell) if err != nil || !snapshot.Selected { t.Fatalf("%s cell is not selected: %#v, %v", name, snapshot, err) } } // The fixture starts with a button focused and two table rows selected. // Tab focuses the table; neither selected row is an unambiguous target. if err := exec.Command("xdotool", "key", "Tab").Run(); err != nil { t.Fatal(err) } for { select { case <-ctx.Done(): t.Fatal("multiselection table focus event was not received") case event := <-adapter.Events(): if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.BusName != window.ID().BusName { continue } if event.Source.Role == "button" || event.Source.Role == "push button" { continue } if event.Source.Role != "table" || event.Source.Name != "Probe items" { t.Fatalf("multiselection focus guessed a row: %#v", event.Source) } return } } } // Run as the first accessibility reader in a real Xorg session, then launch // steam -bigpicture and press Tab twice without leaving Steam. This catches the frame-only provider state // that a synthetic Chromium fixture cannot reproduce. func TestNativeSteamBigPictureFocus(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_STEAM") != "1" { t.Skip("requires a fresh Steam Big Picture launch in a real Xorg session") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() frames := 0 focused := make(map[ObjectID]string) var steamWindow uint64 for { select { case <-ctx.Done(): t.Fatalf("Steam exposed %d frame activations and %d distinct named controls; launch Big Picture and press Tab at least twice before the timeout", frames, len(focused)) case <-adapter.ForegroundChanges(): foreground := adapter.Foreground().Client if steamWindow != 0 && foreground.Known && foreground.Window != steamWindow { t.Fatal("foreground left Steam before two controls were observed") } case event, ok := <-adapter.Events(): if !ok { t.Fatal("AT-SPI events stopped before Steam focus was observed") } pid := event.Foreground.Client.PID if steamWindow != 0 && event.Foreground.Client.Known && event.Foreground.Client.Window != 0 && event.Foreground.Client.Window != steamWindow { t.Fatal("foreground left Steam before two controls were observed") } if pid <= 0 || event.ForegroundMismatch { continue } comm, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) if err != nil || strings.TrimSpace(string(comm)) != "steamwebhelper" { continue } if steamWindow == 0 && event.Foreground.Client.Window != 0 { steamWindow = event.Foreground.Client.Window } switch event.Type { case WindowActivatedEvent: if event.Source.Role == "frame" { frames++ } case FocusEvent, FocusedStateEvent: if event.Type == FocusedStateEvent && event.Detail1 == 0 { continue } default: continue } if !event.Source.Focused || !event.Window.Valid() || event.Source.BusName != event.Window.BusName || strings.TrimSpace(event.Source.Name) == "" || !steamNavigationControl(event.Source.Role) { continue } focused[event.Source.ID()] = event.Source.Role if len(focused) >= 2 { t.Logf("Steam exposed %d distinct focused controls after %d frame activations", len(focused), frames) return } } } } func TestNativeThunarNestedFileCellLabel(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_THUNAR") != "1" { t.Skip("requires an isolated Thunar details view with Alpha and Beta test files") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() tree, err := adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } for _, name := range []string{"Alpha", "Beta"} { child := findNamedReference(t, tree, name) if !child.Valid() { t.Fatalf("Thunar did not expose %s", name) } row, err := tree.Resolve(child) if err != nil || row.Role != "table cell" || row.Name != name { t.Fatalf("Thunar row for %s = %#v, %v", name, row, err) } parent, ok, err := tree.Parent(child) if err != nil || !ok { t.Fatalf("parent of %s: %v", name, err) } snapshot, err := tree.Resolve(parent) if err != nil || snapshot.Role != "tree table" { t.Fatalf("Thunar row %s is not the named child of the file view: %#v, %v", name, snapshot, err) } } if err := exec.Command("xdotool", "key", "Down").Run(); err != nil { t.Fatal(err) } for { select { case <-ctx.Done(): t.Fatal("Thunar details navigation did not expose a file row") case event := <-adapter.Events(): if event.Source.Role != "table cell" || (event.Type != SelectionChangedEvent && event.Type != ActiveDescendantEvent) || (event.Source.Name != "Alpha" && event.Source.Name != "Beta") { continue } if !strings.Contains(event.Source.TableRowText, event.Source.Name) { t.Fatalf("Thunar file name missing from row %q for %q", event.Source.TableRowText, event.Source.Name) } return } } } func steamNavigationControl(role string) bool { switch role { case "button", "push button", "toggle button", "check box", "link", "menu item", "page tab", "list item": return true default: return false } } func TestNativeTreeCaptureDefunctAndShutdownInvalidation(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_ATSPI") != "1" { t.Skip("requires isolated AT-SPI and X11 session") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() tree, err := adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } textRef := findNamedReference(t, tree, "Review text probe") numericRef := findNamedReference(t, tree, "Numeric value probe") numeric, err := tree.Resolve(numericRef) if err != nil || !numeric.ValueKnown || numeric.CurrentValue != 1.25 || numeric.MinimumIncrement != 0.01 { t.Fatalf("native numeric value: known=%v value=%v increment=%v err=%v", numeric.ValueKnown, numeric.CurrentValue, numeric.MinimumIncrement, err) } assertNativeReviewText(t, tree, textRef, "Café 猫Second line") if os.Getenv("CTHULHU_TREE_CONTAINER") == "list" { assert_native_list_position(t, tree, "Persistent control", 1, 7) assert_native_list_position(t, tree, "Removable control", 2, 7) } caretRef := findNamedReference(t, tree, "Caret review probe") if captured, err := tree.VisibleText(caretRef); err != nil || !captured.CaretKnown || captured.CaretOffset != 6 { t.Fatalf("native caret: known=%v offset=%d err=%v", captured.CaretKnown, captured.CaretOffset, err) } clippedRef := findNamedReference(t, tree, "Clipped review probe") clippedText, clippedErr := tree.VisibleText(clippedRef) var clippedContent strings.Builder for _, r := range clippedText.Ranges { clippedContent.WriteString(r.Text) } if clippedErr != nil || !clippedText.Known || !strings.HasPrefix(clippedContent.String(), "Clipped ") || strings.Contains(clippedContent.String(), "OFFSCREEN") { t.Fatalf("native clipped text = %q known=%v err=%v", clippedContent.String(), clippedText.Known, clippedErr) } protected := findNamedReference(t, tree, "Protected text probe") if captured, err := tree.VisibleText(protected); err != nil || captured.Known || len(captured.Ranges) != 0 { t.Fatalf("protected review text was captured: known=%v ranges=%d err=%v", captured.Known, len(captured.Ranges), err) } assertNativeBrailleRouting(t, ctx, adapter) pressTreeProbeKey(t, "t") waitForSoundGeometry(t, ctx, adapter.Events()) waitForWindowName(t, ctx, "Cthulhu Tree Probe Text Changed") for { _, err := tree.VisibleText(textRef) if errors.Is(err, ErrStaleText) { break } if err != nil { t.Fatal(err) } select { case <-ctx.Done(): t.Fatal("text event did not invalidate review text") case <-time.After(10 * time.Millisecond): } } tree, err = adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } assertNativeReviewText(t, tree, textRef, "Changed 猫New line") removable := findNamedReference(t, tree, "Removable control") if !removable.Valid() { t.Fatal("native capture did not contain removable control") } pressTreeProbeKey(t, "r") waitForWindowName(t, ctx, "Cthulhu Tree Probe Removed") waitForStale(t, ctx, tree, removable) treeAfterRemoval, err := adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } if treeAfterRemoval != tree { t.Fatal("recapture replaced the owned tree instance") } persistent := findNamedReference(t, tree, "Persistent control") if !persistent.Valid() { t.Fatal("recapture did not contain persistent control") } if os.Getenv("CTHULHU_TREE_CONTAINER") == "list" { assert_native_list_position(t, tree, "Persistent control", 1, 6) } if os.Getenv("CTHULHU_TREE_CONTAINER") == "box" { container := findNamedReference(t, tree, "Dynamic controls") if !container.Valid() { t.Fatal("dynamic container missing") } pressTreeProbeKey(t, "a") waitForWindowName(t, ctx, "Cthulhu Tree Probe Added") waitForStale(t, ctx, tree, persistent) if _, ok, err := tree.FirstChild(container); ok || !errors.Is(err, ErrIncompleteTree) { t.Fatalf("added children were treated as complete: %v %v", ok, err) } tree, err = adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } if _, ok, err := tree.FirstChild(container); !ok || err != nil { t.Fatalf("recapture failed to restore container traversal: %v %v", ok, err) } if added := findNamedReference(t, tree, "Added control"); !added.Valid() { t.Fatal("recapture did not contain added control") } persistent = findNamedReference(t, tree, "Persistent control") if !persistent.Valid() { t.Fatal("recapture lost persistent control") } } if os.Getenv("CTHULHU_TREE_CONTAINER") == "box" { container := findNamedReference(t, tree, "Dynamic controls") before, err := tree.Geometry(container) if err != nil || !before.BoundsKnown || !before.Visible || !before.Showing { t.Fatalf("native component geometry unavailable: %v %v", before, err) } pressTreeProbeKey(t, "m") waitForWindowName(t, ctx, "Cthulhu Tree Probe Resized") waitForGeometryInvalidation(t, ctx, tree, container) if _, err := tree.Resolve(container); err != nil { t.Fatalf("resize removed object: %v", err) } tree, err = adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } after, err := tree.Geometry(container) if err != nil || !after.BoundsKnown || after.Bounds.Width <= before.Bounds.Width { t.Fatalf("resize geometry = %v %v; previous = %v", after, err, before) } persistent = findNamedReference(t, tree, "Persistent control") pressTreeProbeKey(t, "h") waitForWindowName(t, ctx, "Cthulhu Tree Probe Hidden") waitForGeometryInvalidation(t, ctx, tree, persistent) tree, err = adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } hidden, err := tree.Geometry(persistent) if err != nil || !hidden.VisibilityKnown || hidden.Showing || hidden.BoundsKnown { t.Fatalf("hidden object geometry = %v %v", hidden, err) } } textRef = findNamedReference(t, tree, "Review text probe") pressTreeProbeKey(t, "v") waitForWindowName(t, ctx, "Cthulhu Tree Probe Text Hidden") waitForGeometryInvalidation(t, ctx, tree, textRef) if _, err := tree.VisibleText(textRef); !errors.Is(err, ErrStaleText) { t.Fatalf("hidden text did not become stale: %v", err) } tree, err = adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } if hidden, err := tree.VisibleText(textRef); err != nil || hidden.Known || len(hidden.Ranges) != 0 { t.Fatalf("hidden text retained: known=%v ranges=%d err=%v", hidden.Known, len(hidden.Ranges), err) } if adapter.TreeInvalidations() == 0 { t.Fatal("native defunct event did not invalidate cached objects") } pressTreeProbeKey(t, "w") waitForWindowLifecycle(t, ctx, adapter.Events()) if err := adapter.Stop(ctx); err != nil { t.Fatal(err) } if _, err := tree.Resolve(persistent); !errors.Is(err, ErrStaleObject) { t.Fatalf("adapter stop left a live reference: %v", err) } } func waitForWindowLifecycle(t *testing.T, ctx context.Context, events <-chan Event) { t.Helper() var activated ObjectID deactivated := false for { select { case <-ctx.Done(): t.Fatalf("timed out waiting for window lifecycle: activated=%v deactivated=%v", activated.Valid(), deactivated) case event := <-events: switch event.Type { case WindowActivatedEvent: if event.Source.Name != "Activated control" { continue } if (event.Source.Role != "button" && event.Source.Role != "push button") || !event.Source.Focused { t.Fatalf("activation focus snapshot = %+v", event.Source) } if !event.Window.Valid() || event.Window.Path == event.Source.Path || event.Window.BusName != event.Source.BusName { t.Fatalf("activation ownership missing: source=%+v window=%+v origin=%q", event.Source.ID(), event.Window, event.OriginPath) } activated = event.Window case WindowDeactivatedEvent: if activated.Valid() && event.Window == activated { deactivated = true } case WindowDestroyedEvent: if activated.Valid() && event.Window == activated { if !deactivated { t.Fatal("activated window was destroyed without a deactivation event") } return } } } } } func waitForSoundGeometry(t *testing.T, ctx context.Context, events <-chan Event) { t.Helper() for { select { case <-ctx.Done(): t.Fatal("timed out waiting for captured sound geometry") case event := <-events: if event.Source.SoundGeometry.ObjectKnown && event.Source.SoundGeometry.ReferenceKnown { object := event.Source.SoundGeometry.Object reference := event.Source.SoundGeometry.Reference if object.Valid() && reference.Valid() { return } } } } } func assert_native_list_position(t *testing.T, tree *AccessibleTree, name string, position, size int) { t.Helper() label := findNamedReference(t, tree, name) row, ok, err := tree.Parent(label) if err != nil || !ok { t.Fatalf("%s row missing: %v", name, err) } snapshot, err := tree.Resolve(row) if err != nil || snapshot.PositionInSet != position || snapshot.SetSize != size { t.Fatalf("%s (%s) position=%d size=%d, want %d of %d: %v", name, snapshot.Role, snapshot.PositionInSet, snapshot.SetSize, position, size, err) } } func findNamedReference(t *testing.T, tree *AccessibleTree, name string) ObjectReference { t.Helper() root, err := tree.Root() if err != nil { t.Fatal(err) } pending := []ObjectReference{root} for len(pending) != 0 { last := len(pending) - 1 current := pending[last] pending = pending[:last] snapshot, resolveErr := tree.Resolve(current) if resolveErr != nil { t.Fatal(resolveErr) } if snapshot.Name == name { return current } child, ok, childErr := tree.FirstChild(current) if childErr != nil { t.Fatal(childErr) } for ok { pending = append(pending, child) child, ok, childErr = tree.NextSibling(child) if childErr != nil { t.Fatal(childErr) } } } return ObjectReference{} } func pressTreeProbeKey(t *testing.T, key string) { t.Helper() window := os.Getenv("CTHULHU_TREE_WINDOW") if window == "" { t.Fatal("CTHULHU_TREE_WINDOW is empty") } if err := exec.Command("xdotool", "windowfocus", window, "key", key).Run(); err != nil { t.Fatalf("press %s in tree probe: %v", key, err) } } func waitForStale(t *testing.T, ctx context.Context, tree *AccessibleTree, reference ObjectReference) { t.Helper() ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() for { if _, err := tree.Resolve(reference); errors.Is(err, ErrStaleObject) { return } select { case <-ctx.Done(): t.Fatalf("reference did not become stale: %v", ctx.Err()) case <-ticker.C: } } } func waitForWindowName(t *testing.T, ctx context.Context, name string) { t.Helper() window := os.Getenv("CTHULHU_TREE_WINDOW") ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() for { output, err := exec.Command("xdotool", "getwindowname", window).Output() if err == nil && string(output) == name+"\n" { return } select { case <-ctx.Done(): t.Fatalf("GTK mutation %q was not observed: %v", name, ctx.Err()) case <-ticker.C: } } } func waitForGeometryInvalidation(t *testing.T, ctx context.Context, tree *AccessibleTree, reference ObjectReference) { t.Helper() ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() for { _, err := tree.Geometry(reference) if errors.Is(err, ErrStaleGeometry) { return } if err != nil { t.Fatalf("object identity lost during geometry change: %v", err) } select { case <-ctx.Done(): t.Fatalf("geometry did not become stale: %v", ctx.Err()) case <-ticker.C: } } } func assertNativeReviewText(t *testing.T, tree *AccessibleTree, ref ObjectReference, want string) { t.Helper() captured, err := tree.VisibleText(ref) if err != nil || !captured.Known { for current := ref; current.Valid(); { snap, _ := tree.Resolve(current) bounds, boundsErr := tree.Geometry(current) t.Logf("review ancestor role=%q geometry=%+v err=%v", snap.Role, bounds, boundsErr) parent, ok, _ := tree.Parent(current) if !ok { break } current = parent } t.Fatalf("native visible text unavailable: known=%v limited=%v err=%v", captured.Known, captured.Limited, err) } var content strings.Builder for _, r := range captured.Ranges { content.WriteString(r.Text) } if content.String() != want { t.Fatalf("native text = %q, want %q", content.String(), want) } geometry, err := tree.Geometry(ref) if err != nil || !captured.valid(geometry) { t.Fatalf("invalid native text geometry: %v", err) } } func TestNativeGUITableScreenLockRecovery(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_GUI_TABLE") != "1" { t.Skip("requires isolated GTK table fixture") } ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) if _, err := adapter.CaptureDesktop(ctx); err != nil { t.Fatal(err) } key := func(name string) { t.Helper() if err := exec.CommandContext(ctx, "xdotool", "key", name).Run(); err != nil { t.Fatal(err) } } key("Down") for { select { case event := <-adapter.Events(): if event.Type == SelectionChangedEvent && event.Source.Name == "Beta" { goto locked } case <-ctx.Done(): t.Fatal("initial table focus did not arrive") } } locked: adapter.SetSecureInputActive(true) if _, err := adapter.CaptureDesktop(ctx); err == nil { t.Fatal("desktop capture was allowed while locked") } key("Up") select { case <-adapter.Events(): t.Fatal("AT-SPI content escaped secure mode") case <-time.After(100 * time.Millisecond): } adapter.SetSecureInputActive(false) // The provider retains focus and need not emit any fresh focus event. Unlock // must recover the current focused object without requiring another key. for { select { case event := <-adapter.Events(): if event.Type == WindowActivatedEvent { if event.Source.Name != "Alpha" { t.Fatalf("unlock focus = %q, want Alpha", event.Source.Name) } return } case <-ctx.Done(): t.Fatal("unlock did not recover retained GTK focus") } } }