703 lines
26 KiB
Go
703 lines
26 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"math/big"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/interaction"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
// This uses the source-built external executable and real host/SDK IPC. Only
|
|
// OpenLocal submissions are synthetic; it cannot prove physical-source input.
|
|
func remote_command_fixture(t *testing.T, setup ...func(*tentacleRuntime)) (*tentacleRuntime, *consentTestForms, input.Command) {
|
|
t.Helper()
|
|
program := os.Getenv("CTHULHU_REMOTE_BINARY")
|
|
if program == "" {
|
|
t.Skip("requires the separately built Remote executable")
|
|
}
|
|
directory := t.TempDir()
|
|
spec := tentacle.Spec{ID: "remote", Program: []string{program, "--data-dir", filepath.Join(directory, "state")}, Presentation: true, Interaction: true, SessionEvents: true, RemoteGrants: []string{"output-observe", "remote-present"}}
|
|
data, _ := json.Marshal([]tentacle.Spec{spec})
|
|
launchPath := filepath.Join(directory, "launch.json")
|
|
if err := os.WriteFile(launchPath, data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bindings := &tentacleBindingRecorder{}
|
|
runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), bindings, launchPath, "")
|
|
runtime.output = tentacleOutputFixture(t)
|
|
runtime.remotePolicy = tentacle.NewRemotePolicy()
|
|
runtime.output.gate.remotePolicy = runtime.remotePolicy
|
|
runtime.newManager = func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) }
|
|
forms := &consentTestForms{requests: make(chan consentFormRequest, 1)}
|
|
runtime.forms = forms
|
|
for _, configure := range setup {
|
|
configure(runtime)
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer stopCancel()
|
|
if err := runtime.Stop(stopCtx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
if err := runtime.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var command input.Command
|
|
waitTentacle(t, func() bool {
|
|
runtime.mutex.Lock()
|
|
defer runtime.mutex.Unlock()
|
|
for id, registered := range runtime.commands {
|
|
if registered.id == "remote" {
|
|
command = id
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
runtime.Invoke(ctx, command)
|
|
return runtime, forms, command
|
|
}
|
|
|
|
func next_remote_form(t *testing.T, forms *consentTestForms) consentFormRequest {
|
|
t.Helper()
|
|
select {
|
|
case request := <-forms.requests:
|
|
if request.ctx.Err() != nil {
|
|
t.Fatal("form already cancelled")
|
|
}
|
|
if _, err := interaction.NewSession(request.form); err != nil {
|
|
t.Fatal("invalid form", err)
|
|
}
|
|
return request
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Remote did not present its next local form")
|
|
return consentFormRequest{}
|
|
}
|
|
}
|
|
|
|
func select_remote_relay(t *testing.T, forms *consentTestForms, address, role string) consentFormRequest {
|
|
t.Helper()
|
|
selection := next_remote_form(t, forms)
|
|
if !strings.Contains(selection.form.Title, "Remote relay and role") || selection.form.Elements[0].ID != "cancel" {
|
|
t.Fatal("missing safe endpoint/role selection")
|
|
}
|
|
// Required entry validation applies to every button in native forms. An
|
|
// empty relay must not prevent the initially focused Cancel from working.
|
|
selectionSession, _ := interaction.NewSession(selection.form)
|
|
if effect := selectionSession.Handle(interaction.Event{Key: interaction.Enter}); effect.Activated != "cancel" {
|
|
t.Fatal("empty relay entries blocked the Cancel button")
|
|
}
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
selection.reply <- internalUIResult{Activated: "review-access", Values: map[string]interaction.Value{"host": {Text: host}, "port": {Text: port}, "role": {Selected: role}}}
|
|
consent := next_remote_form(t, forms)
|
|
if !strings.Contains(consent.form.Title, "Remote access for remote") || consent.form.Elements[0].ID != "cancel" {
|
|
t.Fatal("connection skipped reader-owned consent")
|
|
}
|
|
for _, element := range consent.form.Elements {
|
|
if element.Checked {
|
|
t.Fatal("permission preselected")
|
|
}
|
|
}
|
|
return consent
|
|
}
|
|
|
|
func approve_remote_form(t *testing.T, forms *consentTestForms, consent consentFormRequest, role string) consentFormRequest {
|
|
t.Helper()
|
|
grant := "remote-present"
|
|
if role == "controlled" {
|
|
grant = "output-observe"
|
|
}
|
|
consent.reply <- internalUIResult{Activated: "approve", Values: map[string]interaction.Value{grant: {Checked: true}}}
|
|
credentials := next_remote_form(t, forms)
|
|
if credentials.form.Title != "Connect Remote relay" {
|
|
t.Fatal("approval did not reach connection controls", credentials.form.Title)
|
|
}
|
|
for _, element := range credentials.form.Elements {
|
|
if element.ID == "channel-key" && (!element.Secret || element.Text != "") {
|
|
t.Fatal("unmasked or prefilled channel key")
|
|
}
|
|
}
|
|
return credentials
|
|
}
|
|
|
|
func TestRemoteConnectionCommandConsentDenialAndPolicyCancellation(t *testing.T) {
|
|
for _, mode := range []string{"deny", "empty", "lock", "handoff"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
runtime, forms, command := remote_command_fixture(t)
|
|
consent := select_remote_relay(t, forms, "127.0.0.1:6837", "controller")
|
|
switch mode {
|
|
case "deny":
|
|
consent.reply <- internalUIResult{Cancelled: true}
|
|
case "empty":
|
|
consent.reply <- internalUIResult{Activated: "approve"}
|
|
case "lock", "handoff":
|
|
runtime.remotePolicy.SetAvailable(false)
|
|
runtime.remotePolicy.SetAvailable(true)
|
|
}
|
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
|
select {
|
|
case form := <-forms.requests:
|
|
t.Fatal("denied consent opened connection controls", form.form.Title)
|
|
default:
|
|
}
|
|
// The command must recover after a denied/cancelled review, without
|
|
// keeping an awaiting flag or reviving the previous approval.
|
|
runtime.Invoke(t.Context(), command)
|
|
form := next_remote_form(t, forms)
|
|
if !strings.Contains(form.form.Title, "Remote relay and role") {
|
|
t.Fatal("stale setup survived cancellation")
|
|
}
|
|
form.reply <- internalUIResult{Cancelled: true}
|
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteConnectionCommandCancelsInspectionOnHandoff(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer listener.Close()
|
|
accepted := make(chan struct{})
|
|
closed := make(chan struct{})
|
|
go func() {
|
|
connection, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer connection.Close()
|
|
connection.SetDeadline(time.Now().Add(5 * time.Second))
|
|
close(accepted)
|
|
io.Copy(io.Discard, connection)
|
|
close(closed)
|
|
}()
|
|
runtime, forms, _ := remote_command_fixture(t)
|
|
consent := select_remote_relay(t, forms, listener.Addr().String(), "controller")
|
|
credentials := approve_remote_form(t, forms, consent, "controller")
|
|
credentials.reply <- internalUIResult{Activated: "inspect-certificate"}
|
|
inspection := next_remote_form(t, forms)
|
|
select {
|
|
case <-accepted:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("inspection did not open its socket")
|
|
}
|
|
runtime.remotePolicy.SetAvailable(false)
|
|
runtime.remotePolicy.SetAvailable(true)
|
|
select {
|
|
case <-inspection.ctx.Done():
|
|
case <-time.After(time.Second):
|
|
t.Fatal("handoff retained the inspection form")
|
|
}
|
|
select {
|
|
case <-closed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("handoff retained a pre-lease inspection socket")
|
|
}
|
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
|
}
|
|
|
|
func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
|
for _, test := range []struct {
|
|
role, ending string
|
|
playback bool
|
|
}{
|
|
{"controller", "disconnect", false}, {"controlled", "disconnect", false},
|
|
{"controller", "lock", false}, {"controlled", "lock", false},
|
|
{"controller", "handoff", false}, {"controlled", "handoff", false},
|
|
{"controller", "disable", false}, {"controlled", "disable", false},
|
|
{"controller", "disconnect", true}, {"controller", "lock", true},
|
|
{"controller", "handoff", true}, {"controller", "disable", true},
|
|
{"controller", "backend-failure", true},
|
|
} {
|
|
role, ending := test.role, test.ending
|
|
name := role + "/" + ending
|
|
if test.playback {
|
|
name += "/production-speech"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
remoteText := " " + strings.Repeat("é\t世界 ", 1500) + " "
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
template := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
|
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, MinVersion: tls.VersionTLS12})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer listener.Close()
|
|
probed, joined, closed := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
|
speechReady := make(chan struct{})
|
|
relayOutput := make(chan []byte, 64)
|
|
go func() {
|
|
probe, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
probe.SetDeadline(time.Now().Add(5 * time.Second))
|
|
data, _ := io.ReadAll(probe)
|
|
probe.Close()
|
|
if len(data) != 0 {
|
|
t.Error("certificate inspection sent application data")
|
|
}
|
|
close(probed)
|
|
connection, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer connection.Close()
|
|
connection.SetDeadline(time.Now().Add(10 * time.Second))
|
|
reader := bufio.NewReader(connection)
|
|
if _, err := reader.ReadBytes('\n'); err != nil {
|
|
return
|
|
}
|
|
line, err := reader.ReadBytes('\n')
|
|
var join struct {
|
|
Type string `json:"type"`
|
|
Channel string `json:"channel"`
|
|
ConnectionType string `json:"connection_type"`
|
|
}
|
|
wantRole := "master"
|
|
if role == "controlled" {
|
|
wantRole = "slave"
|
|
}
|
|
if err != nil || json.Unmarshal(line, &join) != nil || join.Type != "join" || join.Channel != "synthetic-fixture" || join.ConnectionType != wantRole {
|
|
t.Error("invalid relay join or wrong selected role")
|
|
return
|
|
}
|
|
peerRole := "master"
|
|
if role == "controller" {
|
|
peerRole = "slave"
|
|
}
|
|
io.WriteString(connection, `{"type":"channel_joined","channel":"synthetic-fixture","clients":[{"id":7,"connection_type":"`+peerRole+`"}]}`+"\n")
|
|
close(joined)
|
|
if role == "controller" {
|
|
select {
|
|
case <-speechReady:
|
|
case <-t.Context().Done():
|
|
return
|
|
}
|
|
json.NewEncoder(connection).Encode(map[string]any{"type": "speak", "origin": 7, "priority": 2, "sequence": []any{
|
|
[]any{"CharacterModeCommand", map[string]any{"state": true}},
|
|
[]any{"LangChangeCommand", map[string]any{"lang": "fr"}}, remoteText,
|
|
[]any{"EndUtteranceCommand", map[string]any{}},
|
|
}})
|
|
}
|
|
for {
|
|
frame, err := reader.ReadBytes('\n')
|
|
if err != nil {
|
|
break
|
|
}
|
|
relayOutput <- frame
|
|
}
|
|
close(closed)
|
|
}()
|
|
var server *isolatedVoiceServer
|
|
var voice *remoteSpeechVoice
|
|
var rendered isolatedVoiceUtterance
|
|
var configure []func(*tentacleRuntime)
|
|
if test.playback {
|
|
speaker, fixture := isolated_voice_fixture(t)
|
|
server = fixture
|
|
configure = append(configure, func(runtime *tentacleRuntime) {
|
|
worker := runtime.output.speech
|
|
worker.speaker = speaker
|
|
if err := worker.Start(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := worker.Stop(ctx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
runtime, forms, shortcut := remote_command_fixture(t, configure...)
|
|
if role == "controlled" {
|
|
worker := runtime.output.speech
|
|
worker.speaker = &observationSpeaker{fakeSpeaker{spoken: make(chan string, 64)}}
|
|
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
|
|
worker.reading = func() config.ReadingSettings { return runtime.output.live.Load().Reading }
|
|
if err := worker.Start(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := worker.Stop(ctx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
}
|
|
// Keep the explicit mailbox assembly fixture alongside production
|
|
// playback, which is attached by the actual runtime startup path.
|
|
var deliveries <-chan tentacle.RemoteSpeechDelivery
|
|
if role == "controller" && !test.playback {
|
|
runtime.mutex.Lock()
|
|
for _, command := range runtime.commands {
|
|
if command.id == "remote" {
|
|
deliveries = command.session.EnableRemoteSpeech()
|
|
break
|
|
}
|
|
}
|
|
runtime.mutex.Unlock()
|
|
if deliveries == nil {
|
|
t.Fatal("Remote fixture session unavailable")
|
|
}
|
|
}
|
|
consent := select_remote_relay(t, forms, listener.Addr().String(), role)
|
|
credentials := approve_remote_form(t, forms, consent, role)
|
|
credentials.reply <- internalUIResult{Activated: "inspect-certificate"}
|
|
inspection := next_remote_form(t, forms)
|
|
select {
|
|
case <-probed:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("certificate probe did not finish")
|
|
}
|
|
for inspection.form.Title != "Review Remote relay certificate" {
|
|
inspection.reply <- internalUIResult{Activated: "refresh"}
|
|
inspection = next_remote_form(t, forms)
|
|
}
|
|
inspection.reply <- internalUIResult{Activated: "trust-once"}
|
|
credentials = next_remote_form(t, forms)
|
|
credentials.reply <- internalUIResult{Activated: "connect", Values: map[string]interaction.Value{"channel-key": {Text: "synthetic-fixture"}}}
|
|
status := next_remote_form(t, forms)
|
|
select {
|
|
case <-joined:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Remote did not join the private relay")
|
|
}
|
|
if status.form.Title != "Remote relay status" {
|
|
t.Fatal("missing connection status")
|
|
}
|
|
// Open has already succeeded before the relay can join. Return to
|
|
// ordinary desktop work without holding either runtime slot or form.
|
|
status.reply <- internalUIResult{Activated: "desktop"}
|
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 && len(runtime.interactionSlots) == 0 })
|
|
if status.ctx.Err() == nil {
|
|
t.Fatal("background connection retained its form context")
|
|
}
|
|
if test.playback {
|
|
waitTentacle(t, func() bool {
|
|
worker := runtime.output.speech
|
|
worker.mutex.Lock()
|
|
defer worker.mutex.Unlock()
|
|
return worker.activeDone == nil && len(worker.pending) == 0
|
|
})
|
|
if ending == "backend-failure" {
|
|
server.mutex.Lock()
|
|
server.queryReplies = map[string]string{"GET LANGUAGE": "400 Rejected\r\n"}
|
|
server.mutex.Unlock()
|
|
}
|
|
}
|
|
close(speechReady)
|
|
if ending == "backend-failure" {
|
|
remote_stream_wait(t, closed)
|
|
// Backend failure retires the lease, while the process and
|
|
// mailbox owner remain usable for an explicit new connection.
|
|
runtime.Invoke(t.Context(), shortcut)
|
|
selection := next_remote_form(t, forms)
|
|
if selection.form.Title == "Remote relay status" {
|
|
// An ended attempt remains inspectable until explicitly
|
|
// closed, as with ordinary transport failure.
|
|
selection.reply <- internalUIResult{Activated: "cancel"}
|
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
|
runtime.Invoke(t.Context(), shortcut)
|
|
selection = next_remote_form(t, forms)
|
|
}
|
|
if !strings.Contains(selection.form.Title, "Remote relay and role") {
|
|
t.Fatal("backend failure retained a connected workflow")
|
|
}
|
|
selection.reply <- internalUIResult{Cancelled: true}
|
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
|
server.mutex.Lock()
|
|
for _, connection := range server.connections {
|
|
if connection.priority == "TEXT" && connection.pending != "" {
|
|
t.Error("failed rendering exposed a speech prefix")
|
|
}
|
|
}
|
|
server.mutex.Unlock()
|
|
return
|
|
}
|
|
select {
|
|
case <-closed:
|
|
t.Fatal("return to desktop disconnected the relay")
|
|
default:
|
|
}
|
|
var delivery tentacle.RemoteSpeechDelivery
|
|
if test.playback {
|
|
utterance := server.next_utterance(t)
|
|
for utterance.priority == "MESSAGE" {
|
|
// Returning to desktop produces local status on its ordinary
|
|
// client. The production Remote voice must stay separate.
|
|
utterance = server.next_utterance(t)
|
|
}
|
|
rendered = utterance
|
|
if utterance.text != remoteText || utterance.language != "fr" || utterance.spelling != "on" ||
|
|
utterance.ssml != "off" || utterance.priority != "TEXT" {
|
|
t.Fatal("production owner lost literal speech, metadata or local priority")
|
|
}
|
|
speaker := runtime.output.speech.speaker.(*configuredSpeaker)
|
|
speaker.mu.RLock()
|
|
voice = speaker.remote
|
|
speaker.mu.RUnlock()
|
|
if voice == nil {
|
|
t.Fatal("production owner did not acquire isolated synthesis")
|
|
}
|
|
} else if role == "controller" {
|
|
select {
|
|
case delivery = <-deliveries:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("split relay speech did not reach the reader owner")
|
|
}
|
|
var text strings.Builder
|
|
ends := 0
|
|
for _, speech := range delivery.Speeches {
|
|
if !tentaclewire.ValidRemoteSpeech(speech) || speech.Priority != 2 {
|
|
t.Fatal("invalid speech fragment or changed priority")
|
|
}
|
|
for _, part := range speech.Parts {
|
|
if part.End {
|
|
ends++
|
|
} else {
|
|
if !part.Characters || part.Language != "fr" {
|
|
t.Fatal("split speech lost character/language state")
|
|
}
|
|
text.WriteString(part.Text)
|
|
}
|
|
}
|
|
}
|
|
if len(delivery.Speeches) < 2 || text.String() != remoteText || ends != 1 {
|
|
t.Fatal("reader owner received partial or altered utterance")
|
|
}
|
|
select {
|
|
case <-deliveries:
|
|
t.Fatal("one relay utterance became multiple deliveries")
|
|
default:
|
|
}
|
|
delivery.Release()
|
|
} else {
|
|
// Joining at the relay is earlier than the Tentacle admitting the
|
|
// peer. Poll status before testing actual output rather than
|
|
// depending on a scheduling delay or replaying pre-peer output.
|
|
runtime.Invoke(t.Context(), shortcut)
|
|
status = next_remote_form(t, forms)
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
encoded, _ := json.Marshal(status.form)
|
|
if strings.Contains(string(encoded), "One opposite-role peer has joined") {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("peer did not become ready")
|
|
}
|
|
status.reply <- internalUIResult{Activated: "refresh"}
|
|
status = next_remote_form(t, forms)
|
|
}
|
|
status.reply <- internalUIResult{Activated: "desktop"}
|
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
|
worker := runtime.output.speech
|
|
for _, request := range []speechRequest{
|
|
{Text: "private form", Cause: "internal-ui"},
|
|
{Text: "password", Role: "password text", origin: speechOriginLocal},
|
|
{Text: "local status", origin: speechOriginStatus},
|
|
{Text: "colour " + remoteText, origin: speechOriginLocal},
|
|
} {
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
request := document_observation_request(t, "read-all", review.Result{
|
|
ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{{Contents: []atspi.DocumentContent{{
|
|
Snapshot: atspi.Snapshot{Role: "text"}, Text: "First colour. Second colour.",
|
|
}}}},
|
|
})
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request = current_line_observation_request(t, review.Result{CurrentLineBrowse: true,
|
|
DocumentCaretText: "Current colour.", DocumentPosition: review.DocumentPosition{Snapshot: atspi.Snapshot{Role: "static"}},
|
|
DocumentCaretContext: []atspi.Snapshot{{Role: "static"}, {Role: "paragraph"}, {Role: "document web"}}})
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A joined line can contain a private neighboring fragment even
|
|
// when its caret source and the line's presentation are ordinary.
|
|
caret := atspi.Snapshot{Role: "static"}
|
|
privateLine := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: caret, Text: "private joined text"}},
|
|
SourceContexts: [][]atspi.Snapshot{{caret}, {{Role: "static"}, {Role: "password text"}}}}
|
|
for _, kind := range []string{"caret", "read-all", "current-line"} {
|
|
request := document_observation_request(t, kind, review.Result{
|
|
Moved: true, DocumentCaretCommand: review.CaretNextLine,
|
|
DocumentPosition: review.DocumentPosition{Snapshot: caret}, DocumentCaretText: "private joined text",
|
|
DocumentCaretContext: []atspi.Snapshot{caret, {Role: "document web"}},
|
|
DocumentCaretLine: &privateLine, ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{privateLine},
|
|
})
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, private := range []bool{false, true} {
|
|
result := native_current_line_result()
|
|
if private {
|
|
result.DocumentCaretText = "private native text"
|
|
result.DocumentCaretContext[1].Role = "password text"
|
|
}
|
|
if err := worker.EnqueueAndWait(t.Context(), current_line_observation_request(t, result)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, kind := range []string{"object", "line"} {
|
|
for _, privacy := range []string{"ordinary", "snapshot-ancestor", "stale"} {
|
|
request := review_observation_request(t, kind, review_observation_result(t, kind, privacy))
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
for _, privacy := range []string{"ordinary", "snapshot-ancestor", "missing", "stale"} {
|
|
if err := worker.EnqueueAndWait(t.Context(), where_am_i_observation_request(t, privacy)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, privacy := range []string{"ordinary", "ancestor", "changed-text", "failure"} {
|
|
if err := worker.EnqueueAndWait(t.Context(), native_where_am_i_request(t, privacy)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
list := atspi.Snapshot{BusName: ":1.42", Path: "/relay-list", Role: "list", Name: "Relay items"}
|
|
privateList := list
|
|
privateList.Path, privateList.Name = "/private-list", "Private relay label"
|
|
for _, request := range []speechRequest{
|
|
{origin: speechOriginLocal, Text: "Relay inside", Context: []atspi.Snapshot{list}},
|
|
{origin: speechOriginLocal, Text: "Relay outside", Context: []atspi.Snapshot{}},
|
|
{origin: speechOriginSecret, Text: "Private relay content", Context: []atspi.Snapshot{privateList}},
|
|
{origin: speechOriginLocal, Text: "Relay resumed", Context: []atspi.Snapshot{}},
|
|
} {
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
worker.InterruptForUser()
|
|
var received strings.Builder
|
|
ends, frames := 0, 0
|
|
readOutput:
|
|
for {
|
|
select {
|
|
case frame := <-relayOutput:
|
|
var message struct {
|
|
Type string `json:"type"`
|
|
Sequence []json.RawMessage `json:"sequence"`
|
|
}
|
|
if err := json.Unmarshal(frame, &message); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if message.Type == "cancel" {
|
|
break readOutput
|
|
}
|
|
if message.Type != "speak" {
|
|
t.Fatal("unexpected observed relay message", message.Type)
|
|
}
|
|
frames++
|
|
for _, raw := range message.Sequence {
|
|
var text string
|
|
if json.Unmarshal(raw, &text) == nil {
|
|
received.WriteString(text)
|
|
} else if strings.Contains(string(raw), "EndUtteranceCommand") {
|
|
ends++
|
|
} else {
|
|
t.Fatal("unexpected observed speech command", string(raw))
|
|
}
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("reader output did not reach the relay in order")
|
|
}
|
|
}
|
|
if received.String() != "color "+remoteText+"First color.Second color.Current color.Native color.Review color.staticFlat color. Review color.Review color.staticNative color.textRelay items listRelay insideLeaving Relay items list.Relay outsideRelay resumed" || frames < 18 || ends != 17 {
|
|
t.Fatalf("observation leaked private text or lost resolved output/boundaries: frames=%d ends=%d suffix=%q", frames, ends, received.String()[max(0, received.Len()-160):])
|
|
}
|
|
}
|
|
// Reopening returns to this attempt, with no second consent or
|
|
// saved-key read. Lifecycle retirement must work with no form open.
|
|
switch ending {
|
|
case "disconnect":
|
|
runtime.Invoke(t.Context(), shortcut)
|
|
status = next_remote_form(t, forms)
|
|
if status.form.Title != "Remote relay status" {
|
|
t.Fatal("reopening lost the approved connection")
|
|
}
|
|
status.reply <- internalUIResult{Activated: "cancel"}
|
|
case "lock":
|
|
runtime.output.gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{})
|
|
runtime.output.gate.SetLocked(false, nil, nil, nil, nil, &brailleWorker{})
|
|
case "handoff":
|
|
runtime.output.gate.SetSuspended(true)
|
|
runtime.output.gate.SetSuspended(false)
|
|
case "disable":
|
|
runtime.Disable()
|
|
}
|
|
select {
|
|
case <-closed:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("background connection retained the relay after " + ending)
|
|
}
|
|
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
|
if test.playback {
|
|
remote_stream_wait(t, voice.closed)
|
|
server.mutex.Lock()
|
|
pending := server.connections[rendered.id].pending
|
|
server.mutex.Unlock()
|
|
if pending != "" {
|
|
t.Fatal("revoked production speech survived backend cleanup")
|
|
}
|
|
} else if role == "controller" {
|
|
// Socket cancellation precedes the final sequenced lease close.
|
|
// A reopened status interaction did not issue the original consent,
|
|
// so its result-slot completion is not the lease-close barrier.
|
|
select {
|
|
case <-delivery.Context.Done():
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Disconnect retained remote delivery authority")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|