Files
cthulhu/internal/app/document_native_test.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)
}
}