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