Merge remote-tracking branch 'origin/testing' into testing
This commit is contained in:
commit
a29910d002
7 files changed
+304
-2
No files matched your search
@@ -6,6 +6,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -250,3 +251,193 @@ func TestNativeBrowserActionOwnership(t *testing.T) {
|
||||
previousContent = mode + " mode content"
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeBrowserDisclosureFocus(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
||||
t.Skip("requires private browser disclosure fixture")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
var focus atspi.ObjectID
|
||||
for attempt := 0; attempt < 25 && !focus.Valid(); attempt++ {
|
||||
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil {
|
||||
t.Fatalf("fixture focus: %v %s", err, out)
|
||||
}
|
||||
timer := time.NewTimer(150 * time.Millisecond)
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if isFocusGainEvent(event) && event.Source.Name == "Action fixture start" {
|
||||
focus = event.Source.ID()
|
||||
break drain
|
||||
}
|
||||
case <-timer.C:
|
||||
break drain
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
timer.Stop()
|
||||
}
|
||||
if !focus.Valid() {
|
||||
t.Fatal("fixture heading focus missing")
|
||||
}
|
||||
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)
|
||||
testBrowserDisclosureActions(t, ctx, adapter, worker)
|
||||
}
|
||||
|
||||
func testBrowserDisclosureActions(t *testing.T, ctx context.Context, adapter *atspi.Adapter, worker *review.Worker) {
|
||||
t.Helper()
|
||||
move := func(command review.DocumentCaretCommand) review.Result {
|
||||
t.Helper()
|
||||
if err := worker.MoveDocumentCaret(command); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case result := <-worker.Results():
|
||||
if result.Err != nil || result.CaretErr != nil {
|
||||
t.Fatalf("disclosure navigation failed: %+v", result)
|
||||
}
|
||||
return result
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
return review.Result{}
|
||||
}
|
||||
}
|
||||
waitToggle := func(want string) {
|
||||
t.Helper()
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if event.Type == atspi.AccessibleNameEvent && event.Source.Name == want {
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("disclosure did not report %q: %v", want, ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, method := range []string{"actions", "enter"} {
|
||||
var label atspi.ObjectReference
|
||||
for step := 0; step < 60; step++ {
|
||||
command := review.CaretNextLine
|
||||
if step == 0 {
|
||||
command = review.CaretDocumentStart
|
||||
}
|
||||
result := move(command)
|
||||
if strings.TrimSpace(result.DocumentCaretText) == "Choose category" {
|
||||
label = result.DocumentPosition.Object
|
||||
break
|
||||
}
|
||||
}
|
||||
if !label.Valid() {
|
||||
t.Fatal("disclosure label missing")
|
||||
}
|
||||
waitToggle("Caret settled:Choose category:0:closed")
|
||||
perform := func() {
|
||||
t.Helper()
|
||||
actions, err := adapter.AccessibleActions(ctx, label.ID())
|
||||
if err != nil {
|
||||
t.Fatalf("%s list actions: %v", method, err)
|
||||
}
|
||||
if err := adapter.PerformAccessibleAction(ctx, actions.Target, "clickAncestor"); err != nil {
|
||||
current, currentErr := adapter.AccessibleActions(ctx, label.ID())
|
||||
t.Fatalf("%s perform action: %v; before=%+v after=%+v (%v)", method, err, actions.Target, current.Target, currentErr)
|
||||
}
|
||||
}
|
||||
if method == "actions" {
|
||||
perform()
|
||||
} else {
|
||||
if err := worker.ActivateDocumentCaret(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case result := <-worker.Results():
|
||||
if result.Err != nil {
|
||||
t.Fatalf("%s Enter activation: %v", method, result.Err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
waitToggle("Categories opened")
|
||||
// A real dropdown's scroll container can acquire browser focus when
|
||||
// its first option receives the native caret. Reading within it must
|
||||
// not blur that container and dismiss the rest of the options.
|
||||
optionAvailable := func(result review.Result) {
|
||||
t.Helper()
|
||||
prefix := fmt.Sprintf("Caret settled:%s:%d:", result.DocumentPosition.Snapshot.Name, result.DocumentPosition.Offset)
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if event.Type == atspi.AccessibleNameEvent && strings.HasPrefix(event.Source.Name, prefix) {
|
||||
if event.Source.Name != prefix+"open" {
|
||||
t.Fatalf("%s popup closed after %s", method, result.DocumentCaretCommand)
|
||||
}
|
||||
goto settled
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("missing %s acknowledgement: %v", prefix, ctx.Err())
|
||||
}
|
||||
}
|
||||
settled:
|
||||
if _, err := adapter.AccessibleActions(ctx, result.DocumentPosition.Object.ID()); err != nil {
|
||||
t.Fatalf("%s popup option disappeared after %s: %v", method, result.DocumentCaretCommand, err)
|
||||
}
|
||||
}
|
||||
first := move(review.CaretNextLine)
|
||||
if strings.TrimSpace(first.DocumentCaretText) != "First category" {
|
||||
t.Fatalf("%s first disclosure option: %q", method, first.DocumentCaretText)
|
||||
}
|
||||
optionAvailable(first)
|
||||
character := move(review.CaretNextCharacter)
|
||||
if character.DocumentCaretText != "i" {
|
||||
t.Fatalf("%s disclosure character: got %q, want i", method, character.DocumentCaretText)
|
||||
}
|
||||
optionAvailable(character)
|
||||
optionAvailable(move(review.CaretLineStart))
|
||||
for _, want := range []string{"Second category"} {
|
||||
result := move(review.CaretNextLine)
|
||||
if strings.TrimSpace(result.DocumentCaretText) != want {
|
||||
t.Fatalf("%s disclosure navigation: got %q, want %q", method, result.DocumentCaretText, want)
|
||||
}
|
||||
optionAvailable(result)
|
||||
}
|
||||
// Both options must remain available when browsing backwards too.
|
||||
for _, want := range []string{"First category"} {
|
||||
result := move(review.CaretPreviousLine)
|
||||
if strings.TrimSpace(result.DocumentCaretText) != want {
|
||||
t.Fatalf("%s reverse disclosure navigation: got %q, want %q", method, result.DocumentCaretText, want)
|
||||
}
|
||||
optionAvailable(result)
|
||||
}
|
||||
perform()
|
||||
waitToggle("Categories closed")
|
||||
}
|
||||
// Text caret placement must still reveal offscreen text, and the
|
||||
// explicit component scroll remains necessary for a non-text image.
|
||||
end := move(review.CaretDocumentEnd)
|
||||
if end.DocumentPosition.Snapshot.Name != "Distant image" {
|
||||
t.Fatalf("document end = %q, want distant image", end.DocumentPosition.Snapshot.Name)
|
||||
}
|
||||
waitToggle("Viewport:distant-image")
|
||||
previous := move(review.CaretPreviousLine)
|
||||
if strings.TrimSpace(previous.DocumentCaretText) != "Distant text" {
|
||||
t.Fatalf("offscreen previous line = %q", previous.DocumentCaretText)
|
||||
}
|
||||
waitToggle("Viewport:distant-text")
|
||||
}
|
||||
Reference in new issue
Block a user