// 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 TestNativeBrowserAtomicLiveRegion(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() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stop, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := adapter.Stop(stop); err != nil { t.Error(err) } }() window, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output() if err != nil || len(strings.Fields(string(window))) == 0 { t.Fatalf("browser window: %s: %v", window, err) } if out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Fields(string(window))[0]).CombinedOutput(); err != nil { t.Fatalf("focus: %s: %v", out, err) } for _, test := range []struct { key, marker, want string atomic bool }{ {"F9", "Completed", "Download status: Completed", true}, {"F10", "Verified", "Account status: Verified", true}, {"F11", "Inner update", "Inner update", false}, } { t.Run(test.key, func(t *testing.T) { if out, err := exec.CommandContext(ctx, "xdotool", "key", test.key).CombinedOutput(); err != nil { t.Fatalf("trigger: %s: %v", out, err) } deadline := time.NewTimer(5 * time.Second) defer deadline.Stop() for { select { case event := <-adapter.Events(): if !event.Source.LiveRegion || (!strings.Contains(event.Source.Text, test.marker) && !strings.Contains(event.Payload, test.marker)) { continue } if event.Source.LiveAtomic != test.atomic || event.Source.LiveAtomic && event.Source.Text != test.want { t.Fatalf("atomic=%v text=%q payload=%q; want atomic=%v text=%q", event.Source.LiveAtomic, event.Source.Text, event.Payload, test.atomic, test.want) } if !test.atomic && strings.Contains(event.Source.Text, "Outer context:") { t.Fatalf("crossed atomic=false boundary: %q", event.Source.Text) } return case <-deadline.C: t.Fatal("live update missing") case <-ctx.Done(): t.Fatal(ctx.Err()) } } }) } // A busy region must remain silent while its intermediate contents change. if out, err := exec.CommandContext(ctx, "xdotool", "key", "F12").CombinedOutput(); err != nil { t.Fatalf("busy start: %s %v", out, err) } quiet := time.NewTimer(200 * time.Millisecond) quietLoop: for { select { case event := <-adapter.Events(): if event.Source.LiveRegion && (strings.Contains(event.Source.Text, "Intermediate update") || strings.Contains(event.Payload, "Intermediate update")) { t.Fatalf("busy region announced incomplete text: %+v", event) } case <-quiet.C: break quietLoop case <-ctx.Done(): t.Fatal(ctx.Err()) } } quiet.Stop() if out, err := exec.CommandContext(ctx, "xdotool", "key", "F7").CombinedOutput(); err != nil { t.Fatalf("busy complete: %s %v", out, err) } completion := time.NewTimer(5 * time.Second) defer completion.Stop() for { select { case event := <-adapter.Events(): if event.Source.LiveRegion && strings.Contains(event.Source.Text, "Intermediate update") { if event.Type != TextInsertedEvent && event.Type != ChildrenAddedEvent { t.Fatalf("live completion changed focus/lifecycle: %+v", event) } if event.Source.Text != "Busy status: Intermediate update" { t.Fatalf("completion text: %q", event.Source.Text) } return } case <-completion.C: t.Fatal("busy completion missing") case <-ctx.Done(): t.Fatal(ctx.Err()) } } }