// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) func TestNativeBrowserNamedLinks(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser session") } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "shift+Tab").CombinedOutput(); err != nil { t.Fatalf("fixture focus: %s, %v", output, err) } var focus atspi.ObjectID for !focus.Valid() { select { case event := <-adapter.Events(): if event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.Name == "Probe start" { focus = event.Source.ID() } case <-ctx.Done(): t.Fatal(ctx.Err()) } } tree, err := adapter.CaptureDocument(ctx, focus) if err != nil { t.Fatal(err) } cursor, err := review.NewDocumentCursor(tree) if err != nil { t.Fatal(err) } if _, err := cursor.SyncFocus(ctx, focus); err != nil { t.Fatal(err) } queue := &fakeSpeechQueue{spoken: make(chan string, 8)} for _, step := range []struct { command review.DocumentCaretCommand text string }{ {review.CaretNextLine, "Example Game 2 copies"}, {review.CaretNextLine, "Ordinary multiline link"}, {review.CaretNextCharacter, "r"}, {review.CaretNextWord, "Ordinary"}, {review.CaretNextWord, "multiline"}, {review.CaretNextLine, "Useful image label"}, {review.CaretPreviousLine, "Ordinary multiline link"}, {review.CaretPreviousLine, "Example Game 2 copies"}, } { move, err := cursor.MoveCaret(ctx, adapter, step.command) t.Logf("%v: path=%s role=%s text=%q offset=%d range=%d:%d count=%d", step.command, move.Position.Object.ID().Path, move.Position.Snapshot.Role, move.Text, move.Position.Offset, move.Start, move.End, move.Position.Snapshot.CharacterCount) if err != nil || !move.Moved { t.Fatalf("%v: %+v, %v", step.command, move, err) } if step.text == "Example Game 2 copies" && !move.Position.Snapshot.ExplicitName { t.Fatal("native explicit-name attribute was not captured") } context, err := tree.DocumentContext(move.Position.Object) if err != nil { t.Fatal(err) } result := review.Result{DocumentCaretCommand: step.command, DocumentPosition: move.Position, DocumentCaretText: move.Text, DocumentCaretContext: context.Ancestors, Moved: move.Moved} presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, presentation.DefaultOptions()) select { case spoken := <-queue.spoken: if !strings.Contains(spoken, step.text) { t.Fatalf("%v spoke %q, want %q", step.command, spoken, step.text) } default: t.Fatal("native browse result was silent") } } reference, err := tree.Reference(focus) if err != nil { t.Fatal(err) } document, err := tree.Document(ctx, reference) if err != nil { t.Fatal(err) } var username, emoji atspi.ObjectReference for _, node := range document.Nodes { if node.Snapshot.Role != "static" { continue } switch strings.TrimSpace(node.Snapshot.Name) { case "Username": username = node.Reference case "🧻": emoji = node.Reference } } if !username.Valid() || !emoji.Valid() { t.Fatal("separate display-name and emoji text objects missing") } word, err := adapter.ReadDocumentCaret(ctx, emoji, 0, atspi.DocumentCaretWord) t.Logf("native emoji word: %+v, %v", word, err) if _, err := cursor.SyncFocus(ctx, username.ID()); err != nil { t.Fatal(err) } for _, step := range []struct { command review.DocumentCaretCommand text string }{ {review.CaretNextWord, "Username"}, {review.CaretNextWord, "🧻"}, {review.CaretNextWord, "After"}, {review.CaretPreviousWord, "After"}, {review.CaretPreviousWord, "🧻"}, {review.CaretPreviousWord, "Username"}, } { move, err := cursor.MoveCaret(ctx, adapter, step.command) if err != nil || !move.Moved || strings.TrimSpace(move.Text) != step.text { t.Fatalf("word navigation across emoji, %v: %+v, %v; want %q", step.command, move, err, step.text) } } }