219 lines
5.9 KiB
Go
219 lines
5.9 KiB
Go
// 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, 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)
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
}
|