217 lines
6.0 KiB
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)
|
|
}
|
|
}
|