// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "encoding/json" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/review" ) func TestNativeInternalUIActionsAndEntry(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_INTERNAL_UI") != "1" { t.Skip("requires private GTK/AT-SPI fixture") } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() adapter := atspi.New() keys := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) worker, err := review.NewWorker(adapter, time.Second) if err != nil { t.Fatal(err) } for _, service := range []interface { Start(context.Context) error Stop(context.Context) error }{adapter, worker, keys} { if err := service.Start(ctx); err != nil { t.Fatal(err) } defer service.Stop(context.Background()) } output := &ocrTestOutput{speech: make(chan speechRequest, 256)} ui := &internalUI{keys: keys, emojiSource: adapter, emojiCopier: xclipOCRCopier{}, actions: adapter, interest: worker, speech: output, gate: newFocusGate(keys), events: make(chan input.CommandEvent, 32), requests: make(chan internalUIRequest, 1), done: make(chan struct{})} go ui.run(ctx) focused := make(chan struct{}, 1) done := make(chan struct{}) go func() { defer close(done) for { select { case <-ctx.Done(): return case event := <-keys.Commands(): ui.Handle(event) case event := <-adapter.Events(): if event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0 { worker.SyncFocus(event.Source.ID()) if event.Source.Name == "Run action" { select { case focused <- struct{}{}: default: } } } } } }() defer func() { cancel(); <-ui.done; <-done }() key := func(names ...string) { t.Helper() if data, err := exec.CommandContext(ctx, "xdotool", append([]string{"key"}, names...)...).CombinedOutput(); err != nil { t.Fatalf("keys: %v %s", err, data) } } key("Tab", "shift+Tab") select { case <-focused: case <-ctx.Done(): t.Fatal("button did not gain accessible focus") } original, err := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output() if err != nil { t.Fatal(err) } key("Insert+shift+a") select { case message := <-output.speech: if !strings.Contains(strings.ToLower(message.Text), "click") { t.Fatalf("accessible actions=%q", message.Text) } case <-ctx.Done(): t.Fatal("actions menu did not open") } key("Return") read := func() (int, string) { data, err := os.ReadFile(os.Getenv("CTHULHU_UI_MARKER")) if err != nil { return 0, "" } var value struct { Clicks int `json:"clicks"` Text string `json:"text"` } if json.Unmarshal(data, &value) != nil { return 0, "" } return value.Clicks, value.Text } for { clicks, _ := read() if clicks == 1 { break } if ctx.Err() != nil { t.Fatal("accessible click not performed") } time.Sleep(10 * time.Millisecond) } originalActions, err := adapter.AccessibleActions(ctx, worker.Focus()) if err != nil { t.Fatal(err) } result, err := ui.Open(ctx, interaction.Form{Title: "Example", Elements: []interaction.Element{{ID: "code", Label: "Code", Kind: interaction.Entry}}}) if err != nil { t.Fatal(err) } for keys.InternalUISession() == 0 { if ctx.Err() != nil { t.Fatal("entry did not open") } time.Sleep(time.Millisecond) } key("a", "shift+b", "c", "BackSpace", "exclam", "shift+Home", "ctrl+c") copied: for { select { case message := <-output.speech: if message.Text == "Copied" { break copied } case <-ctx.Done(): t.Fatal("entry copy did not complete") } } clipboardText, err := read_internal_clipboard(ctx) if err != nil || clipboardText != "aB!" { t.Fatalf("copied text = %q, %v", clipboardText, err) } key("BackSpace", "ctrl+v", "d", "Return") select { case submitted := <-result: if submitted.Err != nil || submitted.Values["code"].Text != "aB!d" { t.Fatalf("entry result=%+v", submitted) } case <-ctx.Done(): t.Fatal("entry did not submit") } current, _ := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output() if string(current) != string(original) { t.Fatal("internal UI moved application focus") } clicks, text := read() if clicks != 1 || text != "" { t.Fatalf("UI keys reached GTK: clicks=%d text=%q", clicks, text) } // The same real X11 capture must protect a secret entry. The existing // clipboard remains available for explicit paste, but copy/cut cannot replace it. result, err = ui.Open(ctx, interaction.Form{Title: "Secret fixture", Elements: []interaction.Element{ {ID: "key", Label: "Channel key", Kind: interaction.Entry, Secret: true}, {ID: "submit", Label: "Connect", Kind: interaction.Button}, }}) if err != nil { t.Fatal(err) } for { select { case message := <-output.speech: if message.Text == "Secret fixture. Channel key, password edit, 0 characters" { goto secretOpened } case <-ctx.Done(): t.Fatal("secret form did not open") } } secretOpened: key("s", "e", "c", "r", "e", "t", "Home", "Right", "Delete", "BackSpace", "ctrl+a", "ctrl+c", "ctrl+x", "ctrl+v", "Tab", "shift+Tab", "Return") select { case submitted := <-result: if submitted.Err != nil || submitted.Cancelled || submitted.Values["key"].Text != "aB!" { t.Fatal("secret submission failed") } case <-ctx.Done(): t.Fatal("secret form did not submit") } if clipboard, err := read_internal_clipboard(ctx); err != nil || clipboard != "aB!" { t.Fatal("secret copy/cut changed the clipboard") } allowedSecretSpeech := map[string]bool{ "Inserted 1 character": true, "Inserted 3 characters": true, "Hidden character": true, "Deleted 1 character": true, "Selected all": true, "Copy and cut are unavailable for password fields": true, "Connect, button": true, "Channel key, password edit, 3 characters": true, } for len(output.speech) > 0 { if !allowedSecretSpeech[(<-output.speech).Text] { t.Fatal("unexpected secret field speech") } } if clicks, text := read(); clicks != 1 || text != "" { t.Fatal("secret form keys reached the background application") } current, _ = exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output() if string(current) != string(original) { t.Fatal("secret form changed application focus") } key("Tab", "q") for { _, text = read() if text == "q" { break } if ctx.Err() != nil { t.Fatalf("ordinary typing did not resume: %q", text) } time.Sleep(10 * time.Millisecond) } // Exercise the real shortcut, modal capture, insertion and clipboard opt-out. waitSpeech := func(prefix string) { t.Helper() for { select { case message := <-output.speech: t.Logf("UI speech: %s", message.Text) if strings.HasPrefix(message.Text, prefix) { return } case <-ctx.Done(): t.Fatalf("missing speech %q", prefix) } } } key("Insert+ctrl+e") waitSpeech("Emoji Menu. All tab") key("Tab", "g", "o", "a", "t", "Return") waitSpeech("Inserted Goat") _, text = read() if text != "q🐐" { t.Fatalf("emoji insertion: %q", text) } if clipboard, err := read_internal_clipboard(ctx); err != nil || clipboard != "aB!" { t.Fatalf("clipboard changed with copying off: %q %v", clipboard, err) } // The next invocation remembers copying off and replaces a selection with a // complete multi-codepoint emoji, using character rather than byte offsets. key("ctrl+a", "Insert+ctrl+e") waitSpeech("Emoji Menu. All tab") key("Tab", "a", "s", "t", "r", "o", "n", "a", "u", "t", "Return") waitSpeech("Inserted Astronaut") _, text = read() if text != "🧑‍🚀" { t.Fatalf("emoji selection replacement: %q", text) } if clipboard, err := read_internal_clipboard(ctx); err != nil || clipboard != "aB!" { t.Fatalf("clipboard choice not remembered: %q %v", clipboard, err) } // A cancelled picker has no application or clipboard side effects. key("Insert+ctrl+e") waitSpeech("Emoji Menu. All tab") key("Tab", "g", "Escape") for keys.InternalUIActive() { if ctx.Err() != nil { t.Fatal("picker did not cancel") } time.Sleep(time.Millisecond) } _, text = read() if text != "🧑‍🚀" { t.Fatalf("cancel inserted text: %q", text) } // Restore the earlier fixture state for the remaining transfer checks. key("ctrl+a", "q") for { _, text = read() if text == "q" { break } if ctx.Err() != nil { t.Fatal("typing after emoji did not resume") } time.Sleep(time.Millisecond) } if err := adapter.PerformAccessibleAction(ctx, originalActions.Target, "click"); err == nil { t.Fatal("stale focus action was performed") } clicks, _ = read() if clicks != 1 { t.Fatal("stale action clicked old target") } // Fresh one-line transfer reads share native foreground/password safeguards. item, err := adapter.ReadTransferItem(ctx, worker.Focus(), -1) if err != nil || item.Text != "q" { t.Fatalf("focused entry transfer=%+v %v", item, err) } key("Tab") for { item, err = adapter.ReadTransferItem(ctx, worker.Focus(), -1) if err == nil && item.Text == "First line" { break } if ctx.Err() != nil { t.Fatalf("multiline transfer=%+v %v", item, err) } time.Sleep(10 * time.Millisecond) } // A browser's virtual caret can request another line without moving native focus. item, err = adapter.ReadTransferItem(ctx, worker.Focus(), 12) if err != nil || item.Text != "Second line" { t.Fatalf("virtual transfer=%+v %v", item, err) } key("ctrl+Tab") for { if _, err = adapter.ReadTransferItem(ctx, worker.Focus(), -1); err != nil { break } if ctx.Err() != nil { t.Fatal("protected item was exported") } time.Sleep(10 * time.Millisecond) } }