Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates. Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending.
335 lines
9.8 KiB
Go
335 lines
9.8 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, 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)
|
|
}
|
|
|
|
}
|