// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "context" "os" "os/exec" "runtime" "testing" "time" ) func TestNativeSequentialAdapterLifecycle(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_LIFECYCLE") != "1" { t.Skip("requires private Xvfb/AT-SPI session") } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-c", ` import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk window = Gtk.Window(title="Adapter lifecycle probe") box = Gtk.Box() window.add(box) for name in ("First", "Second"): box.pack_start(Gtk.Button(label=name), True, True, 0) window.show_all() Gtk.main() `) fixture.Stderr = os.Stderr if err := fixture.Start(); err != nil { t.Fatal(err) } defer func() { _ = fixture.Process.Kill() _ = fixture.Wait() }() var stopped []*Adapter first := New() // Start a fresh adapter, then restart the original instance. Events must // reach only the current listener even though the provider stays alive. for cycle, adapter := range []*Adapter{first, New(), first, New()} { if err := adapter.Start(ctx); err != nil { t.Fatalf("cycle %d start: %v", cycle, err) } if cycle == 0 { other := New() if err := other.Start(ctx); err == nil { t.Fatal("concurrent adapter acquired the global AT-SPI loop") } } func() { defer func() { stopCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) defer stop() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("cycle %d stop: %v", cycle, err) } }() var window ObjectReference for !window.Valid() && ctx.Err() == nil { tree, err := adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } window = findNamedReference(t, tree, "Adapter lifecycle probe") } if !window.Valid() { t.Fatal("fixture window missing") } for _, args := range [][]string{{"search", "--sync", "--onlyvisible", "--name", "^Adapter lifecycle probe$", "windowfocus"}, {"key", "Tab"}} { if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("xdotool: %v: %s", err, output) } } for { select { case event := <-adapter.Events(): if event.Type == FocusedStateEvent && event.Detail1 == 1 && (event.Source.Name == "First" || event.Source.Name == "Second") { return } case <-ctx.Done(): t.Fatal("focus event missing after adapter restart") } } }() if adapter.callbackData != nil || adapter.callbackHandle != 0 || adapter.eventListener != nil { t.Fatal("stopped adapter retained native callbacks") } for _, old := range stopped { if old == adapter { continue } select { case event := <-old.Events(): t.Fatalf("stopped adapter received %s", event.Type) default: } } for len(adapter.events) != 0 { <-adapter.events } stopped = append(stopped, adapter) } } func TestNativeAdapterPendingCaptureShutdown(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_LIFECYCLE") != "1" { t.Skip("requires private Xvfb/AT-SPI session") } // Initialize on the GLib owner without starting dispatch so a canceled // request deterministically leaves its idle callback pending at shutdown. func() { nativeAdapterOwner.Lock() defer nativeAdapterOwner.Unlock() runtime.LockOSThread() defer runtime.UnlockOSThread() adapter := New() if err := adapter.initialize(); err != nil { t.Fatal(err) } adapter.ready.Store(true) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() _, err := adapter.CaptureDesktop(ctx) if err != context.DeadlineExceeded || len(adapter.treeRequests) != 1 { t.Fatalf("pending capture = %d, %v", len(adapter.treeRequests), err) } adapter.ready.Store(false) if err := adapter.releaseNativeCallbacks(); err != nil { t.Fatal(err) } if len(adapter.treeRequests) != 0 || adapter.callbackHandle != 0 { t.Fatal("shutdown retained pending capture") } }() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { if err := adapter.Stop(ctx); err != nil { t.Error(err) } }() if _, err := adapter.CaptureDesktop(ctx); err != nil { t.Fatal(err) } } func TestNativeCanceledAdapterStart(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_LIFECYCLE") != "1" { t.Skip("requires private Xvfb/AT-SPI session") } ctx, cancel := context.WithCancel(context.Background()) cancel() adapter := New() if err := adapter.Start(ctx); err != context.Canceled { t.Fatalf("canceled start = %v", err) } if adapter.running.Load() || adapter.ready.Load() || adapter.callbackData != nil || adapter.eventListener != nil { t.Fatal("canceled startup retained native resources") } ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } if err := adapter.Stop(ctx); err != nil { t.Fatal(err) } } func TestNativeAdapterStopRetry(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_LIFECYCLE") != "1" { t.Skip("requires private Xvfb/AT-SPI session") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() old := New() if err := old.Start(ctx); err != nil { t.Fatal(err) } expired, expire := context.WithCancel(ctx) expire() // The quit request can finish after this caller times out. Retrying Stop // must not attach another global quit callback that kills a later adapter. _ = old.Stop(expired) select { case <-old.loopDone: case <-ctx.Done(): t.Fatal("timed-out shutdown did not finish") } adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { if err := adapter.Stop(ctx); err != nil { t.Error(err) } }() if err := old.Stop(ctx); err != nil { t.Fatal(err) } if _, err := adapter.CaptureDesktop(ctx); err != nil { t.Fatal(err) } }