796 lines
25 KiB
Go
796 lines
25 KiB
Go
// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|