254 lines
7.6 KiB
Go
254 lines
7.6 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package atspi
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/logging"
|
|
)
|
|
|
|
func TestNativeFlatReviewPreservesProviderCache(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_REVIEW_CACHE") != "1" {
|
|
t.Skip("requires private Xvfb/AT-SPI session")
|
|
}
|
|
test_native_review_provider(t, "cache")
|
|
}
|
|
|
|
func TestNativeFlatReviewBoundsSlowLongRanges(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_REVIEW_BOUNDS") != "1" {
|
|
t.Skip("requires private Xvfb/AT-SPI session")
|
|
}
|
|
test_native_review_provider(t, "bounds")
|
|
}
|
|
|
|
func TestNativeFlatReviewClippedViewport(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_REVIEW_CLIPPED") != "1" {
|
|
t.Skip("requires private Xvfb/AT-SPI session")
|
|
}
|
|
test_native_review_provider(t, "clipped")
|
|
}
|
|
|
|
func TestNativeFlatReviewLargeTextViewport(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_REVIEW_VIEWPORT") != "1" {
|
|
t.Skip("requires private Xvfb/AT-SPI session")
|
|
}
|
|
// Adapter ownership is process-global; each mode runs in its own process.
|
|
mode := os.Getenv("CTHULHU_NATIVE_REVIEW_VIEWPORT_MODE")
|
|
if mode != "lines" && mode != "long" {
|
|
t.Fatal("set viewport mode to lines or long")
|
|
}
|
|
test_native_review_provider(t, mode)
|
|
}
|
|
|
|
func TestNativeFlatReviewUnavailableSubtree(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_REVIEW_SUBTREE") != "1" {
|
|
t.Skip("requires private Xvfb/AT-SPI session")
|
|
}
|
|
test_native_review_provider(t, "children")
|
|
}
|
|
|
|
func TestNativeFlatReviewZeroAreaGlyph(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_REVIEW_GLYPHS") != "1" {
|
|
t.Skip("requires private Xvfb/AT-SPI session")
|
|
}
|
|
test_native_review_provider(t, "glyphs")
|
|
}
|
|
|
|
func test_native_review_provider(t *testing.T, mode string) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
flags, err := exec.CommandContext(ctx, "pkg-config", "--cflags", "--libs", "gtk+-3.0").Output()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
binary := filepath.Join(t.TempDir(), "review-cache-fixture")
|
|
args := append([]string{"../../tests/review_cache_fixture.c", "-o", binary}, strings.Fields(string(flags))...)
|
|
if output, err := exec.CommandContext(ctx, "cc", args...).CombinedOutput(); err != nil {
|
|
t.Fatalf("build fixture: %v: %s", err, output)
|
|
}
|
|
var diagnostics bytes.Buffer
|
|
adapter := New()
|
|
adapter.SetCaptureLogger(logging.New(&diagnostics, logging.Options{Level: slog.LevelDebug}))
|
|
if err := adapter.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer adapter.Stop(context.Background())
|
|
fixture := exec.CommandContext(ctx, binary, mode)
|
|
fixture.Stderr = os.Stderr
|
|
input, err := fixture.StdinPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
output, err := fixture.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := fixture.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
_ = fixture.Process.Kill()
|
|
_ = fixture.Wait()
|
|
}()
|
|
if output, err := exec.CommandContext(ctx, "xdotool", "search", "--sync", "--onlyvisible", "--name", "^Review cache probe$", "windowfocus").CombinedOutput(); err != nil {
|
|
t.Fatalf("focus fixture: %v: %s", err, output)
|
|
}
|
|
var focus ObjectID
|
|
for !focus.Valid() {
|
|
select {
|
|
case event := <-adapter.Events():
|
|
if event.Type == WindowActivatedEvent && event.WindowTitle == "Review cache probe" {
|
|
focus = event.Source.ID()
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("fixture focus missing")
|
|
}
|
|
}
|
|
scanner := bufio.NewScanner(output)
|
|
command := func(request, want string) {
|
|
t.Helper()
|
|
if _, err := fmt.Fprintln(input, request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !scanner.Scan() || scanner.Text() != want {
|
|
t.Fatalf("fixture %s: %q, %v", request, scanner.Text(), scanner.Err())
|
|
}
|
|
}
|
|
if mode == "bounds" || mode == "clipped" || mode == "long" || mode == "lines" || mode == "glyphs" {
|
|
command("measure", "measuring")
|
|
} else if mode != "children" {
|
|
command("arm", "armed")
|
|
}
|
|
if mode == "lines" {
|
|
command("scroll", "scrolled")
|
|
}
|
|
tree, err := adapter.CaptureReviewWindow(ctx, focus)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var timing struct {
|
|
Message string `json:"message"`
|
|
RootScope string `json:"root_scope"`
|
|
CapturedNodes int `json:"captured_nodes"`
|
|
Stages []captureStageTiming `json:"stages"`
|
|
}
|
|
if err := json.Unmarshal(diagnostics.Bytes(), &timing); err != nil {
|
|
t.Fatalf("capture diagnostics: %v: %s", err, diagnostics.Bytes())
|
|
}
|
|
if timing.Message != "flat review capture timing" || timing.RootScope != "window" || timing.CapturedNodes == 0 {
|
|
t.Fatalf("missing capture scope/progress: %+v", timing)
|
|
}
|
|
if mode == "children" {
|
|
broken := findNamedReference(t, tree, "Broken subtree")
|
|
if !broken.Valid() {
|
|
t.Fatal("broken subtree fixture missing")
|
|
}
|
|
command("arm", "armed")
|
|
tree.InvalidateChildren(broken.ID())
|
|
tree, err = adapter.CaptureReviewWindow(ctx, focus)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot, err := tree.ReviewWindow(ctx, focus)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
omitted, healthy := false, false
|
|
for _, node := range snapshot.Nodes {
|
|
omitted = omitted || node.Snapshot.ID() == broken.ID() && node.ChildrenUnavailable
|
|
for _, span := range node.Text.Ranges {
|
|
healthy = healthy || span.Text == "Cached review text"
|
|
}
|
|
}
|
|
if !omitted || !healthy {
|
|
t.Fatalf("subtree recovery: omission=%v healthy=%v", omitted, healthy)
|
|
}
|
|
return
|
|
}
|
|
for _, want := range []string{"queue-wait", "root-selection", "snapshot", "geometry", "visible-text", "table-viewport", "child-count", "child-enumeration", "tree-installation"} {
|
|
found := false
|
|
for _, stage := range timing.Stages {
|
|
if stage.Stage == want && stage.Calls > 0 && stage.Duration > 0 {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("capture timing missing stage %q: %+v", want, timing)
|
|
}
|
|
}
|
|
if mode == "bounds" || mode == "clipped" || mode == "long" || mode == "lines" || mode == "glyphs" {
|
|
label := findNamedReference(t, tree, "Long review label")
|
|
if !label.Valid() {
|
|
t.Fatal("long label missing from review")
|
|
}
|
|
text, err := tree.VisibleText(label)
|
|
if err != nil || !text.Known || text.Limited || len(text.Ranges) == 0 {
|
|
t.Fatalf("long label text: %+v, %v", text, err)
|
|
}
|
|
if mode == "bounds" {
|
|
command("largest", "largest 128")
|
|
command("repeated", "repeated 0")
|
|
}
|
|
if mode == "lines" && text.Ranges[0].Start <= maxReviewCharacters {
|
|
t.Fatalf("line viewport did not seek scrolled native offsets: %d", text.Ranges[0].Start)
|
|
}
|
|
if mode == "glyphs" {
|
|
for _, span := range text.Ranges {
|
|
if span.Start <= 5 && span.End > 5 {
|
|
t.Fatalf("zero-area glyph included as visible text: %+v", span)
|
|
}
|
|
}
|
|
}
|
|
if mode == "clipped" {
|
|
characters := 0
|
|
for _, span := range text.Ranges {
|
|
characters += utf8.RuneCountInString(span.Text)
|
|
}
|
|
if characters <= maxReviewRanges || len(text.Ranges) > 21 {
|
|
t.Fatalf("fixture must capture hundreds of characters as visual spans: characters=%d ranges=%d", characters, len(text.Ranges))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
label := findNamedReference(t, tree, "Cached review text")
|
|
if !label.Valid() {
|
|
t.Fatal("cached label missing from review")
|
|
}
|
|
command("count", "count 0")
|
|
command("change", "changed")
|
|
for {
|
|
select {
|
|
case event := <-adapter.Events():
|
|
if event.Source.ID() == label.ID() && strings.HasPrefix(event.Type, "object:text-changed:") {
|
|
goto changed
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("text change missing")
|
|
}
|
|
}
|
|
changed:
|
|
tree, err = adapter.CaptureReviewWindow(ctx, focus)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !findNamedReference(t, tree, "Changed review text").Valid() {
|
|
t.Fatal("dirty label retained old provider data")
|
|
}
|
|
}
|