// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package review import ( "context" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) func TestNativeBrowserFragmentLines(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()) // Reach the textarea without enabling the browser's native caret mode. if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "Tab").CombinedOutput(); err != nil { t.Fatalf("focus: %v %s", err, output) } var focus atspi.ObjectID for !focus.Valid() { select { case event := <-adapter.Events(): if (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 == 1) && event.Source.Name == "Fragment probe focus" { 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 := NewDocumentCursor(tree) if err != nil { t.Fatal(err) } if _, err := cursor.SyncFocus(ctx, focus); err != nil { t.Fatal(err) } reference, err := tree.Reference(focus) if err != nil { t.Fatal(err) } document, err := tree.Document(ctx, reference) if err != nil { t.Fatal(err) } glyphCount := 0 for _, node := range document.Nodes { if node.Snapshot.Role == "static" && node.Snapshot.Name == "\uf005" && node.Snapshot.CharacterCount == 1 { glyphCount++ } } if glyphCount < 5 { t.Fatalf("fixture did not expose its star text objects: %d", glyphCount) } for _, step := range []struct { command DocumentCaretCommand want string }{ {CaretDocumentStart, "Fragmented response"}, {CaretNextLine, "One two three."}, {CaretNextLine, "Four five six."}, {CaretPreviousLine, "One two three."}, {CaretLineEnd, "."}, {CaretNextLine, "Four five six."}, {CaretNextLine, "Next paragraph."}, {CaretNextLine, "Before"}, {CaretNextLine, "ordinary link"}, {CaretNextLine, "after."}, {CaretNextLine, "Split heading"}, {CaretNextLine, "Code first"}, {CaretNextLine, ""}, // Preserve the provider's separate preformatted newline. {CaretNextLine, "Code second"}, {CaretNextLine, "Member rank"}, {CaretNextLine, "Join Date"}, // Skip the five separate decorative stars. {CaretPreviousLine, "Member rank"}, {CaretNextLine, "Join Date"}, } { move, err := cursor.MoveCaret(ctx, adapter, step.command) if err != nil || !move.Moved || strings.TrimSpace(move.Text) != step.want { t.Fatalf("%s: %+v, %v; want %q", step.command, move, err, step.want) } } control, err := cursor.MoveCaret(ctx, adapter, CaretNextLine) if err != nil || !control.Moved || control.Position.Snapshot.Role != "push button" && control.Position.Snapshot.Role != "button" || control.Position.Snapshot.Name != "Rating details" { t.Fatalf("labeled icon control: %+v, %v", control, err) } for _, want := range []string{"Mixed \uf005 text.", "★"} { move, err := cursor.MoveCaret(ctx, adapter, CaretNextLine) if err != nil || !move.Moved || strings.TrimSpace(move.Text) != want { t.Fatalf("meaningful symbol: %+v, %v; want %q", move, err, want) } } if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil { t.Fatal(err) } inline, err := cursor.MoveCaret(ctx, adapter, CaretNextLine) if err != nil { t.Fatal(err) } worker, err := NewWorker(adapter, 3*time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(focus) worker.SetDocumentBrailleEnabled(true) if inline.Line == nil || len(inline.Line.Contents) < 2 { t.Fatalf("missing inline fragments: %+v", inline) } content := inline.Line.Contents[1] displayRoute := &documentBrailleRequest{ctx: ctx, focus: focus, document: inline.Position.Document.ID(), target: content.Snapshot.ID(), line: content.RawText, start: content.Start, offset: content.Start + 1, done: make(chan error, 1)} routed := worker.route_document_braille(ctx, cursor, request{brailleRoute: displayRoute}) if routed.Err != nil || !routed.Moved || routed.DocumentPosition.Offset != content.Start+1 || routed.DocumentCaretLine == nil || len(routed.DocumentCaretLine.Contents) < 2 { t.Fatalf("native inline braille route = %+v", routed) } if err := <-displayRoute.done; err != nil { t.Fatal(err) } worker.readDocumentBraille(ctx, routed) // A mismatched displayed source must be rejected by the native owner. ref, _ := tree.Reference(content.Snapshot.ID()) if err := adapter.RouteBraille(ctx, atspi.BrailleRoute{Position: ref, Focus: focus, Line: "changed", Start: content.Start, Offset: content.Start}); err == nil { t.Fatal("native routing accepted changed display text") } if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil { t.Fatal(err) } if _, err := cursor.MoveCaret(ctx, adapter, CaretNextLine); err != nil { t.Fatal(err) } result := worker.readAll(ctx, cursor) if result.Err != nil || len(result.ReadAllLines) < 3 { t.Fatalf("Read All: %+v", result) } for index, want := range []string{"One two three.", "Four five six.", "Next paragraph."} { if got := result.ReadAllLines[index].Contents[0].Text; got != want { t.Fatalf("Read All line %d: %q, want %q", index, got, want) } } rankFound := false for index, line := range result.ReadAllLines { if line.Contents[0].Text == "Member rank" { rankFound = true if index+1 >= len(result.ReadAllLines) || result.ReadAllLines[index+1].Contents[0].Text != "Join Date" { t.Fatal("Read All stopped on decorative rank icons") } } } if !rankFound { t.Fatal("Read All did not reach member rank") } // Fragmented headings must also hand Down to following content as a unit. if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil { t.Fatal(err) } move, err := cursor.Move(ctx, DocumentHeading, 2, false) if err != nil || !move.Moved { t.Fatalf("heading: %+v, %v", move, err) } next, err := cursor.MoveCaret(ctx, adapter, CaretNextLine) if err != nil || next.Text != "Code first" { t.Fatalf("Down after split heading: %+v, %v", next, err) } }