// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package xorg import ( "bufio" "context" "encoding/json" "os" "os/exec" "testing" "time" "git.stormux.org/storm/cthulhu/internal/input" ) func TestNativeOCRReviewInput(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_OCR_REVIEW") != "1" { t.Skip("requires private Xorg display") } for _, layout := range []input.KeyboardLayout{input.DesktopKeyboardLayout, input.LaptopKeyboardLayout} { name := "desktop" if layout == input.LaptopKeyboardLayout { name = "laptop" } t.Run(name, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../../tests/ocr_review_fixture.py") stdin, err := fixture.StdinPipe() if err != nil { t.Fatal(err) } stdout, err := fixture.StdoutPipe() if err != nil { t.Fatal(err) } fixture.Stderr = os.Stderr if err = fixture.Start(); err != nil { t.Fatal(err) } defer func() { stdin.Close(); fixture.Wait() }() scanner := bufio.NewScanner(stdout) if !scanner.Scan() { t.Fatal("fixture did not start") } var windows map[string]uint64 if err = json.Unmarshal(scanner.Bytes(), &windows); err != nil { t.Fatal(err) } type deliveredKey struct { Pressed bool Symbol uint32 Modifiers uint16 Window uint64 } type state struct { Focus uint64 Keys []deliveredKey } action := func(value map[string]any) state { t.Helper() data, _ := json.Marshal(value) if _, err := stdin.Write(append(data, '\n')); err != nil { t.Fatal(err) } if !scanner.Scan() { t.Fatal("fixture did not respond") } var result state if err := json.Unmarshal(scanner.Bytes(), &result); err != nil { t.Fatal(err) } return result } clear := func() { action(map[string]any{"action": "clear"}) } snapshot := func() state { return action(map[string]any{"action": "state"}) } listener := NewCommands([]string{"Insert"}, layout) if err = listener.Start(ctx); err != nil { t.Fatal(err) } defer listener.Stop(context.Background()) key := func(args ...string) { t.Helper() if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("keys: %v %s", err, out) } } defer func() { exec.Command("xdotool", "keyup", "Insert", "Control_L", "Shift_L", "Down", "Escape", "Tab", "o").Run() }() ready := func(predicate func() bool) { t.Helper() for !predicate() { select { case err := <-listener.Failures(): t.Fatal(err) case <-ctx.Done(): t.Fatalf("%v: OCR=%v commandsApplied=%v requested=%v secure=%v", ctx.Err(), listener.ocrApplied.Load(), listener.commandsApplied.Load(), listener.commandsEnabled.Load(), listener.secure.Load()) case <-time.After(time.Millisecond): } } } command := func(want input.Command) input.CommandEvent { t.Helper() select { case got := <-listener.Commands(): if got.Command != want { t.Fatalf("got %s want %s", got.Command, want) } return got case err := <-listener.Failures(): t.Fatal(err) case <-ctx.Done(): t.Fatalf("%v: OCR=%v commandsApplied=%v requested=%v secure=%v", ctx.Err(), listener.ocrApplied.Load(), listener.commandsApplied.Load(), listener.commandsEnabled.Load(), listener.secure.Load()) } return input.CommandEvent{} } drained := func() { ready(func() bool { return !listener.CommandGrabActive() }) } noLeak := func() { t.Helper() drained() got := snapshot() balance := make(map[uint32]int) for _, event := range got.Keys { if event.Symbol != 0xffe1 && event.Symbol != 0xffe3 { t.Fatalf("review leaked navigation: %+v", got) } if event.Pressed { balance[event.Symbol]++ } else { balance[event.Symbol]-- } } for _, count := range balance { if count != 0 { t.Fatalf("unbalanced native modifier: %+v", got) } } if got.Focus != windows["window"] { t.Fatalf("review leaked/changed focus: %+v", got) } } listener.SetBrowseNavigation(true) ready(listener.BrowseNavigationEnabled) listener.SetNativeButtonFocus(true) // A conflict must reject entry and preserve the existing browser context. action(map[string]any{"action": "conflict"}) if err = listener.SetOCRReview(ctx, true); err == nil { t.Fatal("conflicting Ctrl+C grab accepted") } if listener.OCRReviewEnabled() || !listener.BrowseNavigationEnabled() { t.Fatal("failed entry lost old context") } key("key", "h") command(input.DocumentNextHeading) drained() action(map[string]any{"action": "unconflict"}) clear() // Recognition claims Escape only, even when the application's Ctrl+C // grab prevents full review. Arrows and structural letters pass. action(map[string]any{"action": "conflict"}) if err = listener.SetOCRContext(ctx, windows["other"], true); err == nil { t.Fatal("pending entry accepted another foreground") } if err = listener.SetOCRContext(ctx, windows["window"], true); err != nil { t.Fatal(err) } if listener.OCRReviewEnabled() || listener.BrowseNavigationEnabled() { t.Fatal("pending context exposed full review/browser navigation") } key("key", "Down") key("key", "h") gotPending := snapshot() if len(gotPending.Keys) != 4 || gotPending.Keys[0].Symbol != 0xff54 || gotPending.Keys[2].Symbol != 'h' { t.Fatalf("pending navigation: %+v", gotPending) } clear() key("key", "Escape") command(input.OCRDismiss) noLeak() ready(listener.BrowseNavigationEnabled) action(map[string]any{"action": "unconflict"}) clear() // A timed-out entry must never activate when the held key later releases. key("keydown", "Insert") ready(listener.CommandGrabActive) expired, cancelEntry := context.WithTimeout(ctx, 25*time.Millisecond) if err = listener.SetOCRReview(expired, true); err != context.DeadlineExceeded { t.Fatalf("waiting entry: %v", err) } cancelEntry() key("keyup", "Insert") if err = listener.SetOCRReview(ctx, false); err != nil { t.Fatal(err) } if listener.OCRReviewEnabled() { t.Fatal("expired entry activated later") } // Entry waits for the full initiating chord, including modifier release. key("keydown", "Insert") ready(listener.CommandGrabActive) applied := make(chan error, 1) go func() { applied <- listener.SetOCRReview(ctx, true) }() select { case err := <-applied: t.Fatalf("entered on held modifier: %v", err) case <-time.After(25 * time.Millisecond): } key("keyup", "Insert") if err = <-applied; err != nil { t.Fatal(err) } ready(listener.OCRReviewEnabled) clear() for _, test := range []struct { chord string want input.Command }{ {"Down", input.OCRNextLine}, {"Up", input.OCRPreviousLine}, {"Left", input.OCRPreviousCharacter}, {"ctrl+Right", input.OCRNextWord}, {"Home", input.OCRLineStart}, {"ctrl+End", input.OCRDocumentEnd}, {"shift+Down", input.OCRNextLine}, {"ctrl+a", input.OCRSelectAll}, {"ctrl+c", input.OCRCopySelection}, {"Return", input.OCRActivate}, {"space", input.OCRActivate}, } { key("key", test.chord) event := command(test.want) if test.chord == "shift+Down" && event.Chord.Modifiers&1 == 0 { t.Fatal("selection modifier lost") } noLeak() clear() } if layout == input.LaptopKeyboardLayout { key("key", "Insert+o") command(input.ReviewNextLine) noLeak() clear() } // Alt+Tab remains available to the application/window manager. key("key", "alt+Tab") got := snapshot() tabPresses, tabReleases := 0, 0 for _, event := range got.Keys { if event.Symbol == 0xff09 { if event.Pressed { tabPresses++ } else { tabReleases++ } } } if tabPresses != 1 || tabReleases != 1 { t.Fatalf("Alt+Tab did not pass: %+v", got) } clear() // Structural letters reach the app rather than inventing OCR headings. key("key", "h") got = snapshot() if len(got.Keys) != 2 || got.Keys[0].Symbol != 'h' { t.Fatalf("structural letter: %+v", got) } clear() key("key", "ctrl+z") got = snapshot() if len(got.Keys) != 4 { t.Fatalf("unbound Control chord not replayed whole: %+v", got) } clear() // Exit also waits for the complete navigation key release. key("keydown", "Down") command(input.OCRNextLine) go func() { applied <- listener.SetOCRReview(ctx, false) }() select { case err := <-applied: t.Fatalf("exited on held arrow: %v", err) case <-time.After(25 * time.Millisecond): } key("keyup", "Down") if err = <-applied; err != nil { t.Fatal(err) } noLeak() ready(listener.BrowseNavigationEnabled) key("key", "h") command(input.DocumentNextHeading) drained() clear() if err = listener.SetOCRReview(ctx, true); err != nil { t.Fatal(err) } key("key", "Escape") command(input.OCRDismiss) ready(func() bool { return !listener.OCRReviewEnabled() }) noLeak() for _, chord := range []string{"Tab", "shift+Tab"} { if err = listener.SetOCRReview(ctx, true); err != nil { t.Fatal(err) } clear() key("key", chord) command(input.OCRDismissAndPass) ready(func() bool { return !listener.OCRReviewEnabled() }) drained() got = snapshot() presses, releases := 0, 0 for _, event := range got.Keys { if event.Symbol == 0xff09 { if event.Pressed { presses++ } else { releases++ } } } if got.Focus != windows["window"] || presses != 1 || releases != 1 { t.Fatalf("Tab replay: %+v", got) } } // Focus loss retires temporary input; a following arrow reaches the new app. if err = listener.SetOCRReview(ctx, true); err != nil { t.Fatal(err) } listener.SetBrowseNavigation(false) action(map[string]any{"action": "activate", "other": true}) command(input.OCRDismiss) ready(func() bool { return !listener.OCRReviewEnabled() }) clear() key("key", "Down") got = snapshot() if len(got.Keys) != 2 || got.Keys[0].Window != windows["other"] { t.Fatalf("focus-loss routing: %+v", got) } action(map[string]any{"action": "activate"}) if err = listener.SetOCRReview(ctx, true); err != nil { t.Fatal(err) } listener.SetSecureInputActive(true) ready(func() bool { return !listener.ocrApplied.Load() && !listener.CommandGrabsEnabled() }) listener.SetSecureInputActive(false) ready(listener.CommandGrabsEnabled) if listener.OCRReviewEnabled() { t.Fatal("unlock revived OCR") } }) } }