994 lines
29 KiB
Go
994 lines
29 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/braille"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
func TestNativeBrowserDocumentCommands(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
|
t.Skip("requires private browser session")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
|
defer cancel()
|
|
adapter := atspi.New()
|
|
if err := adapter.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer adapter.Stop(context.Background())
|
|
keys := func(chord string) {
|
|
t.Helper()
|
|
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
|
|
t.Fatalf("keys: %v %s", err, out)
|
|
}
|
|
}
|
|
var focus atspi.Snapshot
|
|
for attempt := 0; attempt < 25 && focus.Name != "Browser foundation"; attempt++ {
|
|
keys("Tab")
|
|
timer := time.NewTimer(250 * time.Millisecond)
|
|
drain:
|
|
for {
|
|
select {
|
|
case event := <-adapter.Events():
|
|
if isFocusEvent(event.Type) {
|
|
focus = event.Source
|
|
}
|
|
if focus.Name == "Browser foundation" {
|
|
break drain
|
|
}
|
|
case <-timer.C:
|
|
break drain
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
timer.Stop()
|
|
}
|
|
if focus.Name != "Browser foundation" {
|
|
t.Fatalf("fixture heading focus missing, last %q", focus.Name)
|
|
}
|
|
// Feed real owned native states into the policy without installing web-key
|
|
// grabs. These simple roles do not need the remaining derived traits.
|
|
tree, err := adapter.CaptureDocument(ctx, focus.ID())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref, err := tree.Reference(focus.ID())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
document, err := tree.Document(ctx, ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
type modeTarget struct{ name, role string }
|
|
modeCases := map[modeTarget]review.DocumentMode{
|
|
{"Reading section", "heading"}: review.BrowseMode,
|
|
{"Ordinary unfocusable heading", "heading"}: review.BrowseMode,
|
|
{"Display name", "entry"}: review.FocusMode,
|
|
{"Details", "button"}: review.FocusMode,
|
|
{"Popup actions", "button"}: review.FocusMode,
|
|
{"Popup toggle", "toggle button"}: review.FocusMode,
|
|
{"Ordinary action", "button"}: review.BrowseMode,
|
|
}
|
|
for _, node := range document.Nodes {
|
|
target := modeTarget{node.Snapshot.Name, node.Snapshot.Role}
|
|
want, ok := modeCases[target]
|
|
if !ok {
|
|
continue
|
|
}
|
|
owner, err := tree.DocumentContext(node.Reference)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mode review.DocumentModeState
|
|
if got := mode.Update(review.ModeInput{Context: owner}, review.DefaultModeSettings()); got != want {
|
|
t.Fatalf("native mode for %s (%s): got %v want %v", node.Snapshot.Name, node.Snapshot.Role, got, want)
|
|
}
|
|
delete(modeCases, target)
|
|
}
|
|
if len(modeCases) != 0 {
|
|
t.Fatalf("missing native mode fixtures: %v", modeCases)
|
|
}
|
|
worker, err := review.NewWorker(adapter, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := worker.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer worker.Stop(context.Background())
|
|
worker.SyncFocus(focus.ID())
|
|
commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
|
|
if err := commands.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer commands.Stop(context.Background())
|
|
// This fixture drives the Xorg listener directly. Install the same browse
|
|
// bindings the application requests for an owned web document.
|
|
commands.SetBrowseNavigation(true)
|
|
browseDeadline := time.NewTimer(time.Second)
|
|
defer browseDeadline.Stop()
|
|
browseApplied := time.NewTicker(10 * time.Millisecond)
|
|
defer browseApplied.Stop()
|
|
for !commands.BrowseNavigationEnabled() {
|
|
select {
|
|
case <-browseDeadline.C:
|
|
t.Fatal("browse bindings were not applied")
|
|
case <-browseApplied.C:
|
|
}
|
|
}
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 1)}
|
|
reuseDocument := false
|
|
checkCommand := func(chord, want string) {
|
|
t.Helper()
|
|
keys(chord)
|
|
var command input.Command
|
|
select {
|
|
case event := <-commands.Commands():
|
|
command = event.Command
|
|
case err := <-commands.Failures():
|
|
t.Fatal(err)
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
target, level, backwards := documentReviewCommand(command)
|
|
if target == 0 {
|
|
t.Fatalf("unexpected command %s", command)
|
|
}
|
|
if err := worker.MoveDocument(target, level, backwards); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var result review.Result
|
|
select {
|
|
case result = <-worker.Results():
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
if result.Err != nil {
|
|
t.Fatal(result.Err)
|
|
}
|
|
if result.CaretErr != nil {
|
|
t.Fatal(result.CaretErr)
|
|
}
|
|
if reuseDocument && result.CaptureReason != nil {
|
|
t.Fatalf("unchanged page recaptured for %s: %v (%s)", chord, result.CaptureReason, result.CaptureDuration)
|
|
}
|
|
if result.Moved {
|
|
worker.SyncFocus(result.DocumentPosition.Snapshot.ID())
|
|
}
|
|
presentDocumentResult(slog.New(slog.DiscardHandler), queue, result, presentation.Options{})
|
|
got := <-queue.spoken
|
|
if !strings.HasPrefix(got, want) {
|
|
t.Fatalf("%s: speech %q want %q", chord, got, want)
|
|
}
|
|
t.Logf("%s: %s (elapsed %s, capture %s)", chord, got, result.Elapsed, result.CaptureDuration)
|
|
}
|
|
for index, tc := range []struct{ chord, want string }{
|
|
{"h", "Reading section, heading level 2"},
|
|
{"h", "Ordinary unfocusable heading, heading level 2"},
|
|
{"h", "Embedded heading, heading level 3"},
|
|
{"h", "Browser foundation, heading level 1. Wrapping to top."},
|
|
{"shift+h", "Embedded heading, heading level 3. Wrapping to bottom."},
|
|
{"ctrl+shift+Insert+k", "Example link"},
|
|
{"ctrl+Insert+k", "Example link, link. Wrapping to top."},
|
|
} {
|
|
checkCommand(tc.chord, tc.want)
|
|
// Brave can retire the embedded srcdoc object during frame load.
|
|
// Cache reuse is deterministic only across the parent headings.
|
|
reuseDocument = index < 1
|
|
}
|
|
reuseDocument = false
|
|
keys("ctrl+r")
|
|
for attempt := 0; attempt < 25; attempt++ {
|
|
keys("Tab")
|
|
timer := time.NewTimer(250 * time.Millisecond)
|
|
waitFocus:
|
|
for {
|
|
select {
|
|
case event := <-adapter.Events():
|
|
if isFocusEvent(event.Type) && event.Source.Name == "Browser foundation" && event.Source.ID() != focus.ID() {
|
|
timer.Stop()
|
|
worker.SyncFocus(event.Source.ID())
|
|
checkCommand("h", "Reading section, heading level 2")
|
|
checkCommand("shift+h", "Browser foundation, heading level 1")
|
|
return
|
|
}
|
|
case <-timer.C:
|
|
break waitFocus
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
}
|
|
t.Fatal("reloaded heading focus missing")
|
|
}
|
|
|
|
func TestNativeBrowserEditing(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
|
t.Skip("requires private browser session")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
|
defer cancel()
|
|
adapter := atspi.New()
|
|
if err := adapter.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer adapter.Stop(context.Background())
|
|
listener := xorg.New()
|
|
if err := listener.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer listener.Stop(context.Background())
|
|
var selectedCompletion atomic.Bool
|
|
var browserNameChanged atomic.Bool
|
|
var latestCaret atomic.Pointer[atspi.Event]
|
|
observed := &browserEventProbe{Adapter: adapter, events: make(chan atspi.Event, 64)}
|
|
relayDone := make(chan struct{})
|
|
go func() {
|
|
defer close(relayDone)
|
|
for {
|
|
select {
|
|
case event := <-adapter.Events():
|
|
if event.Type == atspi.AccessibleNameEvent && event.Source.Name == "Cthulhu Browser Probe updated" && event.Source.ToolkitName == "Chromium" {
|
|
browserNameChanged.Store(true)
|
|
}
|
|
if event.Type == atspi.CaretEvent {
|
|
latestCaret.Store(&event)
|
|
}
|
|
if event.Type == atspi.TextInsertedEvent && event.Source.Autocomplete && event.Source.SelectionEnd > event.Source.SelectionStart {
|
|
selectedCompletion.Store(true)
|
|
}
|
|
select {
|
|
case observed.events <- event:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
defer func() { cancel(); <-relayDone }()
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 128), interrupt: make(chan struct{}, 128)}
|
|
logger := slog.New(slog.DiscardHandler)
|
|
inputDone := make(chan struct{})
|
|
go func() {
|
|
defer close(inputDone)
|
|
processInputEvents(ctx, logger, queue, listener, input.EchoSettings{}, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-inputDone }()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, logger, queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
keys := func(chord string) {
|
|
t.Helper()
|
|
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
|
|
t.Fatalf("keys: %v %s", err, out)
|
|
}
|
|
}
|
|
collect := func() []string {
|
|
t.Helper()
|
|
timer := time.NewTimer(180 * time.Millisecond)
|
|
defer timer.Stop()
|
|
var result []string
|
|
for {
|
|
select {
|
|
case text := <-queue.spoken:
|
|
result = append(result, text)
|
|
case <-timer.C:
|
|
return result
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
keys("ctrl+l")
|
|
collect()
|
|
for _, letter := range []string{"c", "t", "h"} {
|
|
for len(queue.interrupt) > 0 {
|
|
<-queue.interrupt
|
|
}
|
|
keys(letter)
|
|
got := collect()
|
|
if len(got) != 1 || got[0] != letter {
|
|
t.Fatalf("typing %q: %q", letter, got)
|
|
}
|
|
if len(queue.interrupt) == 0 {
|
|
t.Fatalf("typing %q did not interrupt speech", letter)
|
|
}
|
|
}
|
|
keys("Down")
|
|
got := collect()
|
|
if len(got) != 1 || !strings.Contains(got[0], "list item") {
|
|
t.Fatalf("explicit suggestion navigation: %q", got)
|
|
}
|
|
keys("Escape")
|
|
keys("Escape")
|
|
keys("Escape")
|
|
collect()
|
|
// Exercise the browser's native caret against non-editable page text.
|
|
keys("F7")
|
|
collect()
|
|
keys("Return")
|
|
collect()
|
|
foundPage := false
|
|
for attempt := 0; attempt < 30 && !foundPage; attempt++ {
|
|
keys("Tab")
|
|
for _, text := range collect() {
|
|
if strings.Contains(text, "Browser foundation, heading") {
|
|
foundPage = true
|
|
}
|
|
}
|
|
}
|
|
if !foundPage {
|
|
t.Fatal("could not focus the page heading")
|
|
}
|
|
keys("ctrl+Home")
|
|
collect()
|
|
keys("Down")
|
|
collect() // The focusable second heading may announce its role as well.
|
|
keys("Down")
|
|
got = collect()
|
|
if len(got) == 0 || got[len(got)-1] != "Plain document text with Café and 猫." {
|
|
t.Fatalf("native page line navigation: %q", got)
|
|
}
|
|
keys("ctrl+Right")
|
|
got = collect()
|
|
if len(got) != 1 || got[0] != "Plain" {
|
|
t.Fatalf("native word navigation: %q", got)
|
|
}
|
|
keys("Right")
|
|
got = collect()
|
|
if len(got) != 1 || got[0] != "d" {
|
|
t.Fatalf("native character navigation: %q", got)
|
|
}
|
|
keys("End")
|
|
collect()
|
|
if event := latestCaret.Load(); event == nil || event.Source.Text != "Plain document text with Café and 猫." {
|
|
t.Fatalf("line snapshot at end of browser text: %+v", event)
|
|
}
|
|
keys("Home")
|
|
collect()
|
|
keys("Down")
|
|
collect()
|
|
keys("Down")
|
|
got = collect()
|
|
if len(got) != 1 || got[0] != "• Registered: September 2026" {
|
|
t.Fatalf("embedded browser line text: %q", got)
|
|
}
|
|
// Preserve the browser-specific suffix. AT-SPI can report this change
|
|
// before the native X11 title cache catches up.
|
|
wantTitle := strings.Replace(adapter.ForegroundTitle().Title.Title, "Cthulhu Browser Probe", "Cthulhu Browser Probe updated", 1)
|
|
keys("F8")
|
|
got = collect()
|
|
if len(got) != 1 || got[0] != wantTitle || !strings.Contains(wantTitle, "Cthulhu Browser Probe updated") || !browserNameChanged.Load() {
|
|
t.Fatalf("browser title update: speech=%q, expected=%q, native event=%v", got, wantTitle, browserNameChanged.Load())
|
|
}
|
|
|
|
// Seed a typed browser URL locally, then replace it with a prefix. This
|
|
// exercises real selected inline completion without external networking.
|
|
keys("ctrl+l")
|
|
collect()
|
|
if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "--delay", "20", "brave://version").CombinedOutput(); err != nil {
|
|
t.Fatalf("type URL: %v %s", err, out)
|
|
}
|
|
collect()
|
|
keys("Return")
|
|
collect()
|
|
keys("ctrl+l")
|
|
collect()
|
|
selectedCompletion.Store(false)
|
|
for _, letter := range []string{"b", "r", "a"} {
|
|
keys(letter)
|
|
got = collect()
|
|
want := letter
|
|
if letter == "b" {
|
|
want = "brave://version"
|
|
}
|
|
if len(got) != 1 || got[0] != want {
|
|
t.Fatalf("inline completion typing %q: %q, want %q", letter, got, want)
|
|
}
|
|
}
|
|
if !selectedCompletion.Load() {
|
|
t.Fatal("inline completion fixture did not produce a selected completion")
|
|
}
|
|
}
|
|
|
|
type browserEventProbe struct {
|
|
*atspi.Adapter
|
|
events chan atspi.Event
|
|
}
|
|
|
|
func (probe *browserEventProbe) Events() <-chan atspi.Event { return probe.events }
|
|
|
|
type readAllProbeQueue struct {
|
|
*fakeSpeechQueue
|
|
reading chan speechRequest
|
|
}
|
|
|
|
type browserBrailleProbe struct{ frames chan braille.Frame }
|
|
|
|
func (probe browserBrailleProbe) EnqueueFrame(frame braille.Frame) {
|
|
select {
|
|
case probe.frames <- frame:
|
|
default:
|
|
// Like the real helper, retain recent output without blocking input
|
|
// while browser chrome produces many updates before an assertion.
|
|
select {
|
|
case <-probe.frames:
|
|
default:
|
|
}
|
|
select {
|
|
case probe.frames <- frame:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (queue *readAllProbeQueue) Enqueue(request speechRequest) {
|
|
if request.Cause == "read-all" {
|
|
queue.reading <- request
|
|
return
|
|
}
|
|
queue.fakeSpeechQueue.Enqueue(request)
|
|
}
|
|
|
|
func TestNativeBrowserModes(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
|
t.Skip("requires private browser session")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
|
defer cancel()
|
|
adapter := atspi.New()
|
|
if err := adapter.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer adapter.Stop(context.Background())
|
|
listener := xorg.New()
|
|
if err := listener.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer listener.Stop(context.Background())
|
|
commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
|
|
if err := commands.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer commands.Stop(context.Background())
|
|
worker, err := review.NewWorker(adapter, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker.SetDocumentBrailleEnabled(true)
|
|
if err = worker.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer worker.Stop(context.Background())
|
|
browser := newBrowserModeController(listener, commands, adapter)
|
|
if err = browser.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer browser.Stop(context.Background())
|
|
queue := &readAllProbeQueue{fakeSpeechQueue: &fakeSpeechQueue{spoken: make(chan string, 256)}, reading: make(chan speechRequest, 1)}
|
|
brailleFrames := make(chan braille.Frame, 256)
|
|
// Shift+Down preserves a visual x position, so its character endpoint
|
|
// depends on fonts and browser layout. Compare speech with the actual
|
|
// native range rather than a hard-coded paragraph prefix.
|
|
nativeSelections := make(chan atspi.Snapshot, 16)
|
|
events := make(chan atspi.Event, 64)
|
|
probe := &browserEventProbe{Adapter: adapter, events: events}
|
|
probeDone := make(chan struct{})
|
|
go func() {
|
|
defer close(probeDone)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case event := <-adapter.Events():
|
|
if event.Type == atspi.CaretEvent && event.Source.Role == "paragraph" &&
|
|
event.Source.TextCaretKnown && event.Source.SelectionEnd > event.Source.SelectionStart {
|
|
select {
|
|
case nativeSelections <- event.Source:
|
|
default:
|
|
}
|
|
}
|
|
select {
|
|
case events <- event:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: queue, braille: browserBrailleProbe{frames: brailleFrames}}, probe, nil, worker, 20*time.Millisecond, nil, browser, nil, nil)
|
|
}()
|
|
commandDone := make(chan struct{})
|
|
go func() {
|
|
defer close(commandDone)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case event := <-commands.Commands():
|
|
command := event.Command
|
|
if command == input.ToggleDocumentMode || command == input.StickyFocusMode || command == input.StickyBrowseMode {
|
|
browser.toggles <- command
|
|
} else if target, level, backwards := documentReviewCommand(command); target != 0 {
|
|
worker.MoveDocument(target, level, backwards)
|
|
} else if caretCommand := documentCaretCommand(command); caretCommand != 0 {
|
|
worker.MoveDocumentCaret(caretCommand)
|
|
} else if unit, step := flatReviewCommand(command); unit != 0 {
|
|
worker.ReadFlat(unit, step)
|
|
} else if command == input.ReadAll {
|
|
worker.ReadAll(ctx)
|
|
} else if command == input.DocumentActivate {
|
|
worker.ActivateDocumentCaret()
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
defer func() { cancel(); <-done; <-commandDone; <-probeDone }()
|
|
keys := func(chord string) {
|
|
t.Helper()
|
|
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
|
|
t.Fatalf("keys: %v %s", err, out)
|
|
}
|
|
}
|
|
wait := func(want string) {
|
|
t.Helper()
|
|
timer := time.NewTimer(3 * time.Second)
|
|
defer timer.Stop()
|
|
var heard []string
|
|
for {
|
|
select {
|
|
case text := <-queue.spoken:
|
|
heard = append(heard, text)
|
|
if strings.Contains(text, "Could not") {
|
|
t.Fatalf("navigation failure: %s", text)
|
|
}
|
|
if text == want {
|
|
t.Logf("heard %s", text)
|
|
return
|
|
}
|
|
case err := <-commands.Failures():
|
|
t.Fatal(err)
|
|
case <-timer.C:
|
|
t.Fatalf("missing %q; heard %q", want, heard)
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
}
|
|
waitPrefix := func(want string) {
|
|
t.Helper()
|
|
timer := time.NewTimer(3 * time.Second)
|
|
defer timer.Stop()
|
|
var heard []string
|
|
for {
|
|
select {
|
|
case text := <-queue.spoken:
|
|
heard = append(heard, text)
|
|
if strings.HasPrefix(text, want) {
|
|
t.Logf("heard %s", text)
|
|
return
|
|
}
|
|
case err := <-commands.Failures():
|
|
t.Fatal(err)
|
|
case <-timer.C:
|
|
t.Fatalf("missing %q; heard %q", want, heard)
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
}
|
|
browseReady := func() {
|
|
t.Helper()
|
|
timer := time.NewTimer(3 * time.Second)
|
|
defer timer.Stop()
|
|
ticker := time.NewTicker(time.Millisecond)
|
|
defer ticker.Stop()
|
|
for !commands.BrowseNavigationEnabled() {
|
|
select {
|
|
case <-ticker.C:
|
|
case <-timer.C:
|
|
t.Fatal("browse grabs did not become ready")
|
|
}
|
|
}
|
|
}
|
|
waitBraille := func(text string, cursor int) braille.Frame {
|
|
t.Helper()
|
|
timer := time.NewTimer(3 * time.Second)
|
|
defer timer.Stop()
|
|
var last braille.Frame
|
|
for {
|
|
select {
|
|
case frame := <-brailleFrames:
|
|
last = frame
|
|
if frame.Text == text && frame.Cursor == cursor {
|
|
// A frame can arrive before the mode controller restores
|
|
// native browse grabs after the preceding caret placement.
|
|
browseReady()
|
|
return frame
|
|
}
|
|
case <-timer.C:
|
|
t.Fatalf("missing braille %q cursor %d; last frame %+v", text, cursor, last)
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
}
|
|
// This proof also runs by itself. Enable native caret browsing so Down
|
|
// moves in document content after structural navigation.
|
|
keys("F7")
|
|
keys("Return")
|
|
// Reload resets focus; Tab reaches the fixture's first focusable heading.
|
|
keys("ctrl+l")
|
|
fixture, err := filepath.Abs("../../tests/browser.html")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "file://"+fixture).CombinedOutput(); err != nil {
|
|
t.Fatalf("load fixture: %v %s", err, out)
|
|
}
|
|
keys("Return")
|
|
waitPrefix("Cthulhu Browser Probe")
|
|
// Browser chrome can insert a tab-strip stop after a reload. Follow the
|
|
// actual focus announcements until the first document heading is reached.
|
|
foundHeading := false
|
|
for attempt := 0; attempt < 20 && !foundHeading; attempt++ {
|
|
keys("Tab")
|
|
timer := time.NewTimer(400 * time.Millisecond)
|
|
searchHeading:
|
|
for {
|
|
select {
|
|
case text := <-queue.spoken:
|
|
if text == "Browser foundation, heading level 1" {
|
|
foundHeading = true
|
|
break searchHeading
|
|
}
|
|
case <-timer.C:
|
|
break searchHeading
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
timer.Stop()
|
|
}
|
|
if !foundHeading {
|
|
t.Fatal("could not focus first document heading after reload")
|
|
}
|
|
browseReady()
|
|
keys("h")
|
|
wait("Reading section, heading level 2")
|
|
if err := worker.ReadFlat(review.Line, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wait("Reading section")
|
|
// Closing a second tab must restore browse keys for the original page.
|
|
keys("ctrl+t")
|
|
keys("ctrl+l")
|
|
waitPrefix("Address and search bar")
|
|
tabFixture, err := filepath.Abs("../../tests/browser-next.html")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "file://"+tabFixture).CombinedOutput(); err != nil {
|
|
t.Fatalf("load second tab: %v %s", err, out)
|
|
}
|
|
keys("Return")
|
|
waitPrefix("Cthulhu Second Page")
|
|
keys("ctrl+w")
|
|
wait("Reading section, heading level 2")
|
|
browseReady()
|
|
// Page changes must reset the virtual position without a Tab/arrow nudge.
|
|
// Exercise both an address-bar load and activation of a real page link.
|
|
keys("ctrl+l")
|
|
nextFixture, err := filepath.Abs("../../tests/browser-next.html")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "file://"+nextFixture).CombinedOutput(); err != nil {
|
|
t.Fatalf("load second fixture: %v %s", err, out)
|
|
}
|
|
keys("Return")
|
|
waitPrefix("Cthulhu Second Page")
|
|
browseReady()
|
|
keys("KP_Add")
|
|
select {
|
|
case request := <-queue.reading:
|
|
var text []string
|
|
for _, part := range request.ReadAll {
|
|
text = append(text, part.Text)
|
|
}
|
|
if got := strings.Join(text, "|"); got != "Fresh page heading|heading level 1|Fresh page content.|Return to browser probe|link" {
|
|
t.Fatalf("read all = %q, error speech = %q", got, request.Text)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("keypad Plus did not produce read all")
|
|
}
|
|
// Native Tab may select a link before any command captures a browse cursor.
|
|
keys("Tab")
|
|
wait("Return to browser probe, link")
|
|
browseReady()
|
|
keys("Return")
|
|
waitPrefix("Cthulhu Browser Probe")
|
|
browseReady()
|
|
keys("h")
|
|
wait("Browser foundation, heading level 1")
|
|
keys("h")
|
|
wait("Reading section, heading level 2")
|
|
browseReady()
|
|
keys("Home")
|
|
wait("R")
|
|
keys("shift+End")
|
|
wait("Reading section selected")
|
|
keys("shift+Down")
|
|
select {
|
|
case snapshot := <-nativeSelections:
|
|
paragraph := []rune("Plain document text with Café and 猫.")
|
|
if snapshot.SelectionStart != 0 || snapshot.SelectionEnd > len(paragraph) ||
|
|
snapshot.BrailleCaret != snapshot.SelectionEnd {
|
|
t.Fatalf("heading-to-paragraph selection endpoints: %+v", snapshot)
|
|
}
|
|
selected := string(paragraph[:snapshot.SelectionEnd])
|
|
if snapshot.SelectionText != selected {
|
|
t.Fatalf("native selected text = %q; range 0:%d = %q", snapshot.SelectionText, snapshot.SelectionEnd, selected)
|
|
}
|
|
t.Logf("native paragraph selection 0:%d, caret %d: %q", snapshot.SelectionEnd, snapshot.BrailleCaret, selected)
|
|
wait(strings.Join(strings.Fields(selected), " ") + " selected")
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Shift+Down did not select text in the paragraph")
|
|
}
|
|
keys("Home")
|
|
wait("P")
|
|
keys("Up")
|
|
wait("Reading section, heading level 2")
|
|
keys("Down")
|
|
wait("Plain document text with Café and 猫.")
|
|
waitBraille("Plain document text with Café and 猫.", 0)
|
|
browseReady()
|
|
keys("k")
|
|
wait("Example link, link")
|
|
keys("shift+F10")
|
|
wait("menu")
|
|
keys("Down")
|
|
waitPrefix("Open link in new tab")
|
|
keys("Down")
|
|
waitPrefix("Open link in new window")
|
|
keys("Escape")
|
|
browseReady()
|
|
keys("Home")
|
|
wait("E")
|
|
keys("ctrl+Right")
|
|
wait("Example, link")
|
|
keys("Return")
|
|
keys("h")
|
|
wait("Ordinary unfocusable heading, heading level 2")
|
|
browseReady()
|
|
keys("Down")
|
|
wait("Text refreshed after the ordinary heading.")
|
|
waitBraille("Text refreshed after the ordinary heading.", 0)
|
|
keys("Right")
|
|
wait("e")
|
|
waitBraille("Text refreshed after the ordinary heading.", 1)
|
|
keys("Home")
|
|
wait("T")
|
|
waitBraille("Text refreshed after the ordinary heading.", 0)
|
|
keys("ctrl+Right")
|
|
wait("Text")
|
|
waitBraille("Text refreshed after the ordinary heading.", 4)
|
|
keys("ctrl+Right")
|
|
wait("refreshed")
|
|
keys("ctrl+Left")
|
|
wait("refreshed")
|
|
keys("Home")
|
|
wait("T")
|
|
routeDisplay := &brailleWorker{document: worker, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {}}
|
|
routeBrowse := func(frame braille.Frame, offset int) {
|
|
t.Helper()
|
|
if frame.Document == nil || len(frame.Routes) == 0 {
|
|
t.Fatalf("browse frame has no document routes: %+v", frame)
|
|
}
|
|
routeDisplay.EnqueueFrame(frame)
|
|
routeDisplay.route(ctx, braille.RoutingEvent{ID: routeDisplay.current().ID, Offset: offset})
|
|
}
|
|
routeBrowse(waitBraille("Text refreshed after the ordinary heading.", 0), 1)
|
|
waitBraille("Text refreshed after the ordinary heading.", 1)
|
|
keys("Right")
|
|
wait("x")
|
|
waitBraille("Text refreshed after the ordinary heading.", 2)
|
|
keys("Home")
|
|
wait("T")
|
|
checkSelectionBraille := func(cursor, selected int) braille.Frame {
|
|
t.Helper()
|
|
frame := waitBraille("Text refreshed after the ordinary heading. $l", cursor)
|
|
for index := 0; index < 3; index++ {
|
|
want := byte(0)
|
|
if index < selected {
|
|
want = 0xc0
|
|
}
|
|
if len(frame.Dots) <= index || frame.Dots[index] != want {
|
|
t.Fatalf("selection braille dots = %v; selected characters = %d", frame.Dots, selected)
|
|
}
|
|
}
|
|
return frame
|
|
}
|
|
keys("shift+Right")
|
|
wait("T selected")
|
|
checkSelectionBraille(1, 1)
|
|
keys("shift+Right")
|
|
wait("e selected")
|
|
checkSelectionBraille(2, 2)
|
|
keys("shift+Left")
|
|
wait("e unselected")
|
|
selectionFrame := checkSelectionBraille(1, 1)
|
|
// Routing a native selection frame clears selection and updates Go's cursor.
|
|
routeBrowse(selectionFrame, 1)
|
|
waitBraille("Text refreshed after the ordinary heading.", 1)
|
|
// Resume Go navigation from the native endpoint, then reverse it.
|
|
keys("Right")
|
|
wait("x")
|
|
waitBraille("Text refreshed after the ordinary heading.", 2)
|
|
keys("Left")
|
|
wait("e")
|
|
waitBraille("Text refreshed after the ordinary heading.", 1)
|
|
keys("Home")
|
|
wait("T")
|
|
for _, text := range []string{"e", "x", "t", "space"} {
|
|
keys("Right")
|
|
wait(text)
|
|
}
|
|
keys("End")
|
|
wait(".")
|
|
keys("ctrl+Left")
|
|
wait("heading")
|
|
keys("shift+h")
|
|
wait("Ordinary unfocusable heading, heading level 2")
|
|
keys("shift+h")
|
|
wait("Reading section, heading level 2")
|
|
keys("ctrl+Home")
|
|
wait("Browser foundation, heading level 1")
|
|
// The next native Tab must follow the new caret position, not the old link.
|
|
keys("Tab")
|
|
wait("Reading section, heading level 2")
|
|
browseReady()
|
|
keys("b")
|
|
waitPrefix("Details, button")
|
|
keys("shift+x")
|
|
waitPrefix("Remember choice, check box")
|
|
keys("shift+l")
|
|
wait("Example list, 2 items")
|
|
// The separator follows the list in document order, so this is forward
|
|
// navigation. Shift+S correctly reports the previous-separator boundary.
|
|
keys("s")
|
|
waitPrefix("Section separator, separator")
|
|
keys("k")
|
|
wait("Example link, link")
|
|
keys("Tab")
|
|
wait("Focus mode")
|
|
keys("h")
|
|
wait("h")
|
|
keys("k")
|
|
wait("k")
|
|
keys("Insert+a")
|
|
wait("Browse mode")
|
|
browseReady()
|
|
keys("h")
|
|
wait("Ordinary unfocusable heading, heading level 2")
|
|
keys("Insert+a")
|
|
wait("Focus mode")
|
|
keys("Insert+a")
|
|
wait("Focus mode is sticky.")
|
|
keys("Insert+a")
|
|
wait("Browse mode is sticky.")
|
|
// Separate ordinary toggles from the 500 ms multi-press sequence.
|
|
time.Sleep(550 * time.Millisecond)
|
|
keys("Insert+a")
|
|
wait("Focus mode")
|
|
time.Sleep(550 * time.Millisecond)
|
|
keys("Insert+a")
|
|
wait("Browse mode")
|
|
keys("ctrl+l")
|
|
// The chrome focus presentation is not stable across browser releases;
|
|
// Where Am I isn't needed to establish pass-through: typing must be heard.
|
|
keys("h")
|
|
wait("h")
|
|
keys("Escape")
|
|
keys("Escape")
|
|
found := false
|
|
for attempt := 0; attempt < 25 && !found; attempt++ {
|
|
keys("Tab")
|
|
timer := time.NewTimer(200 * time.Millisecond)
|
|
nextFocus:
|
|
for {
|
|
select {
|
|
case text := <-queue.spoken:
|
|
if text == "Browser foundation, heading level 1" {
|
|
found = true
|
|
break nextFocus
|
|
}
|
|
case <-timer.C:
|
|
break nextFocus
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
timer.Stop()
|
|
}
|
|
if !found {
|
|
t.Fatal("could not return to page heading from chrome")
|
|
}
|
|
browseReady()
|
|
keys("h")
|
|
wait("Reading section, heading level 2")
|
|
readAll := func() speechRequest {
|
|
t.Helper()
|
|
keys("KP_Add")
|
|
select {
|
|
case request := <-queue.reading:
|
|
if len(request.ReadAll) == 0 {
|
|
t.Fatalf("read all failed: %q", request.Text)
|
|
}
|
|
for _, part := range request.ReadAll {
|
|
if strings.Contains(part.Text, "private-probe-value") {
|
|
t.Fatal("read all exposed password text")
|
|
}
|
|
}
|
|
return request
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("read all did not complete")
|
|
return speechRequest{}
|
|
}
|
|
}
|
|
reading := readAll()
|
|
if len(reading.ReadAll) < 3 || reading.ReadAll[2].Text != "Plain document text with Café and 猫." {
|
|
t.Fatal("reading sequence did not continue after the heading text and role")
|
|
}
|
|
// A real backend invokes this only upon SSIP BEGIN. Resume should use
|
|
// the spoken line rather than the original heading or end of the page.
|
|
reading.ReadAll[2].Began()
|
|
resumed := readAll()
|
|
if resumed.ReadAll[0].Text != "Plain document text with Café and 猫." {
|
|
t.Fatalf("resumed from %q", resumed.ReadAll[0].Text)
|
|
}
|
|
}
|