Files
cthulhu/internal/atspi/lifecycle_native_test.go

217 lines
6.0 KiB
Go

// 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)
}
}