// 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" "strings" "testing" "time" ) func TestNativeBrowserCatalogCapture(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser session") } ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() adapter := New() adapter.SetCaptureSoundGeometry(true) if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) // Move away from the autofocus control and back to obtain a fresh event. if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "shift+Tab").CombinedOutput(); err != nil { t.Fatalf("focus catalog: %v %s", err, out) } var focus ObjectID for !focus.Valid() { select { case event := <-adapter.Events(): if event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.Name == "Choose language" { focus = event.Source.ID() } case <-ctx.Done(): t.Fatal(ctx.Err()) } } started := time.Now() captureContext, captureCancel := context.WithTimeout(ctx, 2*time.Second) tree, err := adapter.CaptureDocument(captureContext, focus) captureCancel() elapsed := time.Since(started) if err != nil { t.Fatal(err) } root, _ := tree.Root() document, err := tree.Document(ctx, root) if err != nil { t.Fatal(err) } t.Logf("cold catalog capture: %v, %d nodes", elapsed, len(document.Nodes)) if len(document.Nodes) < 4800 { t.Fatalf("catalog capture lost nodes: %d", len(document.Nodes)) } lastHeading := findNamedReference(t, tree, "Movie 599") line, err := adapter.ReadDocumentCaret(ctx, lastHeading, 0, DocumentCaretBrailleLine) if err != nil || strings.TrimSpace(line.Text) != "Movie 599" { t.Fatalf("last catalog heading text = %+v, %v", line, err) } focused, err := tree.Resolve(findNamedReference(t, tree, "Choose language")) if err != nil || !focused.SoundGeometry.ObjectKnown || !focused.SoundGeometry.ReferenceKnown { t.Fatalf("catalog control sound geometry = %+v, %v", focused.SoundGeometry, err) } if elapsed >= 2*time.Second { t.Fatalf("cold catalog capture exceeded runtime budget: %v", elapsed) } // Dirty semantics/children must refresh even though cold nodes reuse the // native event cache. Removed objects must not survive the refresh. if out, err := exec.CommandContext(ctx, "xdotool", "key", "F9").CombinedOutput(); err != nil { t.Fatalf("update catalog: %v %s", err, out) } for { select { case event := <-adapter.Events(): if event.Type != AccessibleNameEvent || event.Source.Name != "Catalog updated" { continue } captureContext, captureCancel = context.WithTimeout(ctx, 2*time.Second) tree, err = adapter.CaptureDocument(captureContext, focus) captureCancel() if err != nil { t.Fatal(err) } findNamedReference(t, tree, "Updated movie") findNamedReference(t, tree, "New movie") if _, err := tree.Resolve(lastHeading); err == nil { t.Fatal("removed catalog heading remained live") } return case <-ctx.Done(): t.Fatal(ctx.Err()) } } }