// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/review" ) func TestNativeBrowserDisabledFieldsRemainReadable(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 := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) command := func(args ...string) { t.Helper() if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("xdotool: %v: %s", err, output) } } windows, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output() if err != nil || len(strings.Fields(string(windows))) == 0 { t.Fatalf("fixture window: %s %v", windows, err) } command("windowfocus", strings.Fields(string(windows))[0]) var focus atspi.ObjectID for attempt := 0; attempt < 12 && !focus.Valid(); attempt++ { command("windowfocus", strings.Fields(string(windows))[0]) command("key", "--clearmodifiers", "Tab") timer := time.NewTimer(300 * time.Millisecond) waitFocus: for { select { case event := <-adapter.Events(): if (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 == 1) && event.Source.Name == "Read only answer" { focus = event.Source.ID() break waitFocus } case <-timer.C: break waitFocus case <-ctx.Done(): t.Fatal(ctx.Err()) } } timer.Stop() } if !focus.Valid() { t.Fatal("fixture focus missing") } // Capture before the transition to exercise invalidation of a retained tree. if _, err := adapter.CaptureDocument(ctx, focus); err != nil { t.Fatal(err) } command("windowfocus", strings.Fields(string(windows))[0]) command("key", "F9") for { select { case event := <-adapter.Events(): if event.Type != "object:state-changed:sensitive" || event.Source.Name != "Answer" || event.Detail1 != 0 { continue } case <-ctx.Done(): t.Fatal("disabled state update missing: ", ctx.Err()) } break } worker, err := review.NewWorker(adapter, 2*time.Second) if err != nil { t.Fatal(err) } if err := worker.Start(ctx); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) worker.SyncFocus(focus) for _, step := range []struct { command review.DocumentCaretCommand want string }{ {review.CaretDocumentStart, "Before fields"}, {review.CaretNextLine, ""}, // Keep the named row, then enter its disabled fields. {review.CaretNextLine, "Solved"}, {review.CaretNextCharacter, "o"}, {review.CaretNextLine, "Confirmed"}, {review.CaretNextLine, "After fields"}, {review.CaretPreviousLine, "Confirmed"}, {review.CaretPreviousLine, "Solved"}, } { if err := worker.MoveDocumentCaret(step.command); err != nil { t.Fatal(err) } select { case result := <-worker.Results(): if result.Err != nil || !result.Moved || result.DocumentCaretText != step.want { t.Fatalf("%s: text=%q moved=%t err=%v; want %q", step.command, result.DocumentCaretText, result.Moved, result.Err, step.want) } case <-ctx.Done(): t.Fatal(ctx.Err()) } } }