// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "fmt" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/review" ) func TestNativeBrowserActionOwnership(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser action fixture") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) var focus atspi.ObjectID for attempt := 0; attempt < 25 && !focus.Valid(); attempt++ { if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil { t.Fatalf("fixture focus: %v %s", err, out) } timer := time.NewTimer(150 * time.Millisecond) drain: for { select { case event := <-adapter.Events(): if isFocusGainEvent(event) && event.Source.Name == "Action fixture start" { focus = event.Source.ID() break drain } case <-timer.C: break drain case <-ctx.Done(): t.Fatal(ctx.Err()) } } timer.Stop() } if !focus.Valid() { t.Fatal("fixture heading focus missing") } tree, err := adapter.CaptureDocument(ctx, focus) if err != nil { t.Fatal(err) } root, _ := tree.Root() document, err := tree.Document(ctx, root) if err != nil { t.Fatal(err) } anchors := map[string]atspi.ObjectReference{} labels := map[string]atspi.ObjectReference{} for _, node := range document.Nodes { snapshot := node.Snapshot if snapshot.ElementID == "Basic" || snapshot.ElementID == "Advanced" { actions, err := adapter.AccessibleActions(ctx, node.Reference.ID()) if err != nil { t.Fatal(err) } t.Logf("%s anchor actions: %+v", snapshot.ElementID, actions.Actions) if snapshot.Role != "section" || snapshot.HTMLTag != "a" || snapshot.Focusable || !snapshot.SupportsClick { t.Fatalf("custom anchor lost activation semantics: %+v", snapshot) } anchors[strings.ToUpper(snapshot.ElementID)] = node.Reference } if snapshot.Role == "static" && (snapshot.Name == "BASIC" || snapshot.Name == "ADVANCED") { labels[snapshot.Name] = node.Reference actions, err := adapter.AccessibleActions(ctx, node.Reference.ID()) if err != nil { t.Fatal(err) } found := false for _, action := range actions.Actions { found = found || action.Name == "clickAncestor" } if !found { t.Fatalf("label does not reproduce Chromium ancestor activation: %+v", actions) } } if snapshot.Role == "static" || snapshot.ElementID == "inactive" || snapshot.Role == "paragraph" || atspi.IsWebDocumentRole(snapshot.Role) { if snapshot.SupportsClick { t.Fatalf("passive text/container became a clickable: %+v", snapshot) } } } if len(anchors) != 2 || len(labels) != 2 { t.Fatalf("missing mode controls: %v, %v", anchors, labels) } cursor, _ := review.NewDocumentCursor(tree) if _, err := cursor.SyncFocus(ctx, focus); err != nil { t.Fatal(err) } counts := map[string]int{} for step := 0; step < 30; step++ { command := review.CaretNextLine if step == 0 { command = review.CaretDocumentStart } move, err := cursor.MoveCaret(ctx, adapter, command) if err != nil { t.Fatal(err) } if !move.Moved { break } name := strings.TrimSpace(move.Text) if _, ok := labels[name]; ok { counts[name]++ target, err := cursor.ActivationTarget() if err != nil || target != anchors[name] { t.Fatalf("label %s activates %v: %v", name, target, err) } } } if counts["BASIC"] != 1 || counts["ADVANCED"] != 1 { t.Fatalf("mode reading counts: %v", counts) } // Structural clickable navigation discovers the owners, not their labels. cursor, _ = review.NewDocumentCursor(tree) cursor.SyncFocus(ctx, focus) found := map[atspi.ObjectID]bool{} for step := 0; step < 12; step++ { move, err := cursor.Move(ctx, review.DocumentClickable, 0, false) if err != nil { t.Fatal(err) } if !move.Moved || found[move.Position.Object.ID()] { break } found[move.Position.Object.ID()] = true } for name, anchor := range anchors { if !found[anchor.ID()] || found[labels[name].ID()] { t.Fatalf("clickable discovery for %s: %v", name, found) } } 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) await_result := func() review.Result { t.Helper() select { case result := <-worker.Results(): if result.Err != nil || result.CaretErr != nil { t.Fatalf("document command failed: %+v", result) } return result case <-ctx.Done(): t.Fatal(ctx.Err()) return review.Result{} } } move_caret := func(command review.DocumentCaretCommand) review.Result { t.Helper() if err := worker.MoveDocumentCaret(command); err != nil { t.Fatal(err) } return await_result() } previousContent := "Initial mode content" for _, mode := range []string{"Basic", "Advanced"} { // Use the reader's command path so asynchronous replacement events // exercise its bounded recapture and cursor recovery. move_caret(review.CaretDocumentStart) reached := false for step := 0; step < 16; step++ { move := move_caret(review.CaretNextLine) if strings.TrimSpace(move.DocumentCaretText) == strings.ToUpper(mode) { reached = true break } } if !reached { t.Fatalf("browse caret could not reach %s", mode) } if err := worker.ActivateDocumentCaret(); err != nil { t.Fatal(err) } if result := await_result(); !result.DocumentActivate { t.Fatalf("missing activation result: %+v", result) } activated := false for !activated { select { case event := <-adapter.Events(): activated = event.Type == atspi.AccessibleNameEvent && event.Source.Name == mode+" activated" case <-ctx.Done(): t.Fatalf("%s action did not reach its delegated handler: %v", mode, ctx.Err()) } } // The content iframe changes asynchronously. Capture until its new // document is available and ensure the old document is gone. for { tree, err = adapter.CaptureDocument(ctx, focus) if err != nil { t.Fatal(err) } root, _ = tree.Root() document, err = tree.Document(ctx, root) if err != nil { t.Fatal(err) } ready, obsolete := false, false for _, node := range document.Nodes { ready = ready || node.Snapshot.Name == mode+" mode content" obsolete = obsolete || node.Snapshot.Name == previousContent } if ready { if obsolete { t.Fatalf("replacement retained %q", previousContent) } break } select { case <-time.After(20 * time.Millisecond): case <-ctx.Done(): t.Fatal("replacement content missing", ctx.Err()) } } readNewContent := false for step := 0; step < 8; step++ { move := move_caret(review.CaretNextLine) if strings.TrimSpace(move.DocumentCaretText) == mode+" mode content" { readNewContent = true break } } if !readNewContent { t.Fatalf("browse caret could not reach %s replacement content", mode) } previousContent = mode + " mode content" } } func TestNativeBrowserDisclosureFocus(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser disclosure fixture") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) var focus atspi.ObjectID for attempt := 0; attempt < 25 && !focus.Valid(); attempt++ { if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil { t.Fatalf("fixture focus: %v %s", err, out) } timer := time.NewTimer(150 * time.Millisecond) drain: for { select { case event := <-adapter.Events(): if isFocusGainEvent(event) && event.Source.Name == "Action fixture start" { focus = event.Source.ID() break drain } case <-timer.C: break drain case <-ctx.Done(): t.Fatal(ctx.Err()) } } timer.Stop() } if !focus.Valid() { t.Fatal("fixture heading focus missing") } 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) testBrowserDisclosureActions(t, ctx, adapter, worker) } func testBrowserDisclosureActions(t *testing.T, ctx context.Context, adapter *atspi.Adapter, worker *review.Worker) { t.Helper() move := func(command review.DocumentCaretCommand) review.Result { t.Helper() if err := worker.MoveDocumentCaret(command); err != nil { t.Fatal(err) } select { case result := <-worker.Results(): if result.Err != nil || result.CaretErr != nil { t.Fatalf("disclosure navigation failed: %+v", result) } return result case <-ctx.Done(): t.Fatal(ctx.Err()) return review.Result{} } } waitToggle := func(want string) { t.Helper() for { select { case event := <-adapter.Events(): if event.Type == atspi.AccessibleNameEvent && event.Source.Name == want { return } case <-ctx.Done(): t.Fatalf("disclosure did not report %q: %v", want, ctx.Err()) } } } for _, method := range []string{"actions", "enter"} { var label atspi.ObjectReference for step := 0; step < 60; step++ { command := review.CaretNextLine if step == 0 { command = review.CaretDocumentStart } result := move(command) if strings.TrimSpace(result.DocumentCaretText) == "Choose category" { label = result.DocumentPosition.Object break } } if !label.Valid() { t.Fatal("disclosure label missing") } waitToggle("Caret settled:Choose category:0:closed") perform := func() { t.Helper() actions, err := adapter.AccessibleActions(ctx, label.ID()) if err != nil { t.Fatalf("%s list actions: %v", method, err) } if err := adapter.PerformAccessibleAction(ctx, actions.Target, "clickAncestor"); err != nil { current, currentErr := adapter.AccessibleActions(ctx, label.ID()) t.Fatalf("%s perform action: %v; before=%+v after=%+v (%v)", method, err, actions.Target, current.Target, currentErr) } } if method == "actions" { perform() } else { if err := worker.ActivateDocumentCaret(); err != nil { t.Fatal(err) } select { case result := <-worker.Results(): if result.Err != nil { t.Fatalf("%s Enter activation: %v", method, result.Err) } case <-ctx.Done(): t.Fatal(ctx.Err()) } } waitToggle("Categories opened") // A real dropdown's scroll container can acquire browser focus when // its first option receives the native caret. Reading within it must // not blur that container and dismiss the rest of the options. optionAvailable := func(result review.Result) { t.Helper() prefix := fmt.Sprintf("Caret settled:%s:%d:", result.DocumentPosition.Snapshot.Name, result.DocumentPosition.Offset) for { select { case event := <-adapter.Events(): if event.Type == atspi.AccessibleNameEvent && strings.HasPrefix(event.Source.Name, prefix) { if event.Source.Name != prefix+"open" { t.Fatalf("%s popup closed after %s", method, result.DocumentCaretCommand) } goto settled } case <-ctx.Done(): t.Fatalf("missing %s acknowledgement: %v", prefix, ctx.Err()) } } settled: if _, err := adapter.AccessibleActions(ctx, result.DocumentPosition.Object.ID()); err != nil { t.Fatalf("%s popup option disappeared after %s: %v", method, result.DocumentCaretCommand, err) } } first := move(review.CaretNextLine) if strings.TrimSpace(first.DocumentCaretText) != "First category" { t.Fatalf("%s first disclosure option: %q", method, first.DocumentCaretText) } optionAvailable(first) character := move(review.CaretNextCharacter) if character.DocumentCaretText != "i" { t.Fatalf("%s disclosure character: got %q, want i", method, character.DocumentCaretText) } optionAvailable(character) optionAvailable(move(review.CaretLineStart)) for _, want := range []string{"Second category"} { result := move(review.CaretNextLine) if strings.TrimSpace(result.DocumentCaretText) != want { t.Fatalf("%s disclosure navigation: got %q, want %q", method, result.DocumentCaretText, want) } optionAvailable(result) } // Both options must remain available when browsing backwards too. for _, want := range []string{"First category"} { result := move(review.CaretPreviousLine) if strings.TrimSpace(result.DocumentCaretText) != want { t.Fatalf("%s reverse disclosure navigation: got %q, want %q", method, result.DocumentCaretText, want) } optionAvailable(result) } perform() waitToggle("Categories closed") } // Text caret placement must still reveal offscreen text, and the // explicit component scroll remains necessary for a non-text image. end := move(review.CaretDocumentEnd) if end.DocumentPosition.Snapshot.Name != "Distant image" { t.Fatalf("document end = %q, want distant image", end.DocumentPosition.Snapshot.Name) } waitToggle("Viewport:distant-image") previous := move(review.CaretPreviousLine) if strings.TrimSpace(previous.DocumentCaretText) != "Distant text" { t.Fatalf("offscreen previous line = %q", previous.DocumentCaretText) } waitToggle("Viewport:distant-text") }