Web fixes.
This commit is contained in:
1 parent
914d174925
commit
453206b85b
11 files changed
+643
-25
No files matched your search
@@ -0,0 +1,228 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/review"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNativeBrowserActionOwnership(t *testing.T) {
|
||||||
|
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
||||||
|
t.Skip("requires private browser action 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")
|
||||||
|
}
|
||||||
|
tree, err := adapter.CaptureDocument(ctx, focus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root, _ := tree.Root()
|
||||||
|
document, err := tree.Document(ctx, root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
anchors := map[string]atspi.ObjectReference{}
|
||||||
|
labels := map[string]atspi.ObjectReference{}
|
||||||
|
for _, node := range document.Nodes {
|
||||||
|
snapshot := node.Snapshot
|
||||||
|
if snapshot.ElementID == "Basic" || snapshot.ElementID == "Advanced" {
|
||||||
|
actions, err := adapter.AccessibleActions(ctx, node.Reference.ID())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("%s anchor actions: %+v", snapshot.ElementID, actions.Actions)
|
||||||
|
if snapshot.Role != "section" || snapshot.HTMLTag != "a" || snapshot.Focusable || !snapshot.SupportsClick {
|
||||||
|
t.Fatalf("custom anchor lost activation semantics: %+v", snapshot)
|
||||||
|
}
|
||||||
|
anchors[strings.ToUpper(snapshot.ElementID)] = node.Reference
|
||||||
|
}
|
||||||
|
if snapshot.Role == "static" && (snapshot.Name == "BASIC" || snapshot.Name == "ADVANCED") {
|
||||||
|
labels[snapshot.Name] = node.Reference
|
||||||
|
actions, err := adapter.AccessibleActions(ctx, node.Reference.ID())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, action := range actions.Actions {
|
||||||
|
found = found || action.Name == "clickAncestor"
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("label does not reproduce Chromium ancestor activation: %+v", actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshot.Role == "static" || snapshot.ElementID == "inactive" || snapshot.Role == "paragraph" || atspi.IsWebDocumentRole(snapshot.Role) {
|
||||||
|
if snapshot.SupportsClick {
|
||||||
|
t.Fatalf("passive text/container became a clickable: %+v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(anchors) != 2 || len(labels) != 2 {
|
||||||
|
t.Fatalf("missing mode controls: %v, %v", anchors, labels)
|
||||||
|
}
|
||||||
|
cursor, _ := review.NewDocumentCursor(tree)
|
||||||
|
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
counts := map[string]int{}
|
||||||
|
for step := 0; step < 30; step++ {
|
||||||
|
command := review.CaretNextLine
|
||||||
|
if step == 0 {
|
||||||
|
command = review.CaretDocumentStart
|
||||||
|
}
|
||||||
|
move, err := cursor.MoveCaret(ctx, adapter, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !move.Moved {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(move.Text)
|
||||||
|
if _, ok := labels[name]; ok {
|
||||||
|
counts[name]++
|
||||||
|
target, err := cursor.ActivationTarget()
|
||||||
|
if err != nil || target != anchors[name] {
|
||||||
|
t.Fatalf("label %s activates %v: %v", name, target, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if counts["BASIC"] != 1 || counts["ADVANCED"] != 1 {
|
||||||
|
t.Fatalf("mode reading counts: %v", counts)
|
||||||
|
}
|
||||||
|
// Structural clickable navigation discovers the owners, not their labels.
|
||||||
|
cursor, _ = review.NewDocumentCursor(tree)
|
||||||
|
cursor.SyncFocus(ctx, focus)
|
||||||
|
found := map[atspi.ObjectID]bool{}
|
||||||
|
for step := 0; step < 12; step++ {
|
||||||
|
move, err := cursor.Move(ctx, review.DocumentClickable, 0, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !move.Moved || found[move.Position.Object.ID()] {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
found[move.Position.Object.ID()] = true
|
||||||
|
}
|
||||||
|
for name, anchor := range anchors {
|
||||||
|
if !found[anchor.ID()] || found[labels[name].ID()] {
|
||||||
|
t.Fatalf("clickable discovery for %s: %v", name, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, mode := range []string{"Basic", "Advanced"} {
|
||||||
|
// Reacquire references after each iframe replacement. Enter's target
|
||||||
|
// still comes from the text leaf, with native focus on the start heading.
|
||||||
|
tree, err = adapter.CaptureDocument(ctx, focus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root, _ = tree.Root()
|
||||||
|
document, err = tree.Document(ctx, root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var label atspi.ObjectID
|
||||||
|
for _, node := range document.Nodes {
|
||||||
|
if node.Snapshot.Role == "static" && node.Snapshot.Name == strings.ToUpper(mode) {
|
||||||
|
label = node.Reference.ID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor, _ = review.NewDocumentCursor(tree)
|
||||||
|
if _, err := cursor.SyncFocus(ctx, label); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target, err := cursor.ActivationTarget()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := adapter.ActivateDocumentObject(ctx, target, focus); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
activated := false
|
||||||
|
for !activated {
|
||||||
|
select {
|
||||||
|
case event := <-adapter.Events():
|
||||||
|
activated = event.Type == atspi.AccessibleNameEvent && event.Source.Name == mode+" activated"
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatalf("%s action did not reach its delegated handler: %v", mode, ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The content iframe changes asynchronously. Capture until its new
|
||||||
|
// document is available and ensure the old document is gone.
|
||||||
|
for {
|
||||||
|
tree, err = adapter.CaptureDocument(ctx, focus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root, _ = tree.Root()
|
||||||
|
document, err = tree.Document(ctx, root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ready := false
|
||||||
|
for _, node := range document.Nodes {
|
||||||
|
ready = ready || node.Snapshot.Name == mode+" mode content"
|
||||||
|
}
|
||||||
|
if ready {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("replacement content missing", ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readNewContent := false
|
||||||
|
for step := 0; step < 8; step++ {
|
||||||
|
move, err := cursor.MoveCaret(ctx, adapter, review.CaretNextLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading after %s activation: %v", mode, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(move.Text) == mode+" mode content" {
|
||||||
|
readNewContent = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !readNewContent {
|
||||||
|
t.Fatalf("browse caret could not reach %s replacement content", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/input"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/review"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exercise real Xorg keys, native focus echoes, the review worker and the
|
||||||
|
// event-loop presentation filter together. No audio or desktop reader is used.
|
||||||
|
func TestNativeBrowserCaretLinkSounds(t *testing.T) {
|
||||||
|
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
||||||
|
t.Skip("requires private browser sound fixture")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
adapter := atspi.New()
|
||||||
|
adapter.SetCaptureSoundGeometry(true)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
output := &ocrTestOutput{speech: make(chan speechRequest, 128)}
|
||||||
|
options := presentation.Options{EnableSound: true, PlaySoundForRole: true,
|
||||||
|
SpatializeObjectSounds: true, RoleSoundPresentation: "sound_and_speech"}
|
||||||
|
loopDone, commandDone := make(chan struct{}), make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(loopDone)
|
||||||
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, worker,
|
||||||
|
20*time.Millisecond, nil, browser, nil, nil, options)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer close(commandDone)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case event := <-commands.Commands():
|
||||||
|
browser.observeCommand(event.Command)
|
||||||
|
if command := documentCaretCommand(event.Command); command != 0 {
|
||||||
|
worker.MoveDocumentCaret(command)
|
||||||
|
} else if target, level, backwards := documentReviewCommand(event.Command); target != 0 {
|
||||||
|
worker.MoveDocument(target, level, backwards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer func() { cancel(); <-loopDone; <-commandDone }()
|
||||||
|
key := 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 for a presentation, then retain late native focus echoes through a
|
||||||
|
// quiet interval. Counting every link cue catches conflicting duplicates.
|
||||||
|
collect := func(want string) []speechRequest {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.NewTimer(3 * time.Second)
|
||||||
|
defer deadline.Stop()
|
||||||
|
quiet := time.NewTimer(200 * time.Millisecond)
|
||||||
|
defer quiet.Stop()
|
||||||
|
matched := false
|
||||||
|
var requests []speechRequest
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case request := <-output.speech:
|
||||||
|
requests = append(requests, request)
|
||||||
|
matched = matched || strings.Contains(request.Text, want)
|
||||||
|
quiet.Reset(200 * time.Millisecond)
|
||||||
|
case <-quiet.C:
|
||||||
|
if matched {
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
case <-deadline.C:
|
||||||
|
t.Fatalf("missing %q; requests=%+v", want, requests)
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal(ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heading := false
|
||||||
|
for attempt := 0; attempt < 25 && !heading; attempt++ {
|
||||||
|
key("Tab")
|
||||||
|
timer := time.NewTimer(150 * time.Millisecond)
|
||||||
|
drain:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case request := <-output.speech:
|
||||||
|
if strings.Contains(request.Text, "Sound fixture start") {
|
||||||
|
heading = true
|
||||||
|
break drain
|
||||||
|
}
|
||||||
|
case <-timer.C:
|
||||||
|
break drain
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal(ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
if !heading {
|
||||||
|
t.Fatal("fixture heading focus missing")
|
||||||
|
}
|
||||||
|
check := func(chord, want string, pan *float64) float64 {
|
||||||
|
t.Helper()
|
||||||
|
key(chord)
|
||||||
|
requests := collect(want)
|
||||||
|
var pans []float64
|
||||||
|
for _, request := range requests {
|
||||||
|
for _, cue := range request.Sound {
|
||||||
|
if cue.Name == "link" || cue.Name == "link_visited" {
|
||||||
|
pans = append(pans, cue.Pan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pans) != 1 {
|
||||||
|
t.Fatalf("%s produced %d link cues: %+v", chord, len(pans), requests)
|
||||||
|
}
|
||||||
|
if pan != nil && math.Abs(pans[0]-*pan) > 0.025 {
|
||||||
|
t.Fatalf("%s pan=%g, Tab pan=%g", chord, pans[0], *pan)
|
||||||
|
}
|
||||||
|
return pans[0]
|
||||||
|
}
|
||||||
|
for _, size := range []string{"1200 800", "900 600"} {
|
||||||
|
args := append([]string{"getwindowfocus", "windowsize"}, strings.Fields(size)...)
|
||||||
|
if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("resize: %v %s", err, out)
|
||||||
|
}
|
||||||
|
key("ctrl+Home")
|
||||||
|
collect("Sound fixture start")
|
||||||
|
names := []string{"Left link label", "Center link label", "Right link label"}
|
||||||
|
pans := make([]float64, 3)
|
||||||
|
for index, name := range names {
|
||||||
|
pans[index] = check("Tab", name, nil)
|
||||||
|
}
|
||||||
|
if pans[0] > -0.5 || math.Abs(pans[1]) > 0.2 || pans[2] < 0.5 {
|
||||||
|
t.Fatalf("native link positions: %v", pans)
|
||||||
|
}
|
||||||
|
key("ctrl+Home")
|
||||||
|
collect("Sound fixture start")
|
||||||
|
for index, name := range names {
|
||||||
|
check("Down", name, &pans[index])
|
||||||
|
}
|
||||||
|
check("ctrl+Right", "link", &pans[2])
|
||||||
|
check("ctrl+Left", "link", &pans[2])
|
||||||
|
check("Up", names[1], &pans[1])
|
||||||
|
check("k", names[2], &pans[2])
|
||||||
|
t.Logf("window %s: Tab, arrows, words and structural links agree at pans %v", size, pans)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,56 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret,
|
|||||||
static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot);
|
static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot);
|
||||||
static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining);
|
static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining);
|
||||||
|
|
||||||
|
// Chromium exposes doDefault even on inert text. Only an anchor with a label
|
||||||
|
// explicitly offering ancestor activation supplies evidence of a custom action.
|
||||||
|
// Never turn every descendant of a delegated click handler into a clickable.
|
||||||
|
static gboolean cthulhu_anchor_has_click_label(AtspiAccessible *source) {
|
||||||
|
gint count = atspi_accessible_get_child_count(source, NULL);
|
||||||
|
for (gint index = 0; index < MIN(count, 16); index++) {
|
||||||
|
AtspiAccessible *child = atspi_accessible_get_child_at_index(source, index, NULL);
|
||||||
|
if (child == NULL) continue;
|
||||||
|
AtspiRole role = atspi_accessible_get_role(child, NULL);
|
||||||
|
gboolean label = role == ATSPI_ROLE_STATIC || role == ATSPI_ROLE_TEXT;
|
||||||
|
gboolean found = FALSE;
|
||||||
|
AtspiStateSet *states = atspi_accessible_get_state_set(child);
|
||||||
|
gboolean passive = states != NULL &&
|
||||||
|
!atspi_state_set_contains(states, ATSPI_STATE_FOCUSABLE) &&
|
||||||
|
!atspi_state_set_contains(states, ATSPI_STATE_EDITABLE);
|
||||||
|
if (states != NULL) g_object_unref(states);
|
||||||
|
if (passive && label) {
|
||||||
|
AtspiAction *action = atspi_accessible_get_action_iface(child);
|
||||||
|
if (action != NULL) {
|
||||||
|
gint actions = MIN(atspi_action_get_n_actions(action, NULL), 16);
|
||||||
|
for (gint i = 0; i < actions && !found; i++) {
|
||||||
|
gchar *name = atspi_action_get_name(action, i, NULL);
|
||||||
|
found = name != NULL && g_ascii_strcasecmp(name, "clickAncestor") == 0;
|
||||||
|
g_free(name);
|
||||||
|
}
|
||||||
|
g_object_unref(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g_object_unref(child);
|
||||||
|
if (found) return TRUE;
|
||||||
|
}
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by capture and activation so a discovered target can actually be
|
||||||
|
// invoked. Lower numbers retain the existing preference for direct actions.
|
||||||
|
gint cthulhu_document_action_priority(AtspiAccessible *source, const gchar *name, gboolean anchor) {
|
||||||
|
if (name == NULL) return 0;
|
||||||
|
const gchar *direct[] = {"click", "click-ancestor", "press", "jump", "open", "activate"};
|
||||||
|
for (guint index = 0; index < G_N_ELEMENTS(direct); index++) {
|
||||||
|
if (g_ascii_strcasecmp(name, direct[index]) == 0) return index + 1;
|
||||||
|
}
|
||||||
|
if (!anchor) return 0;
|
||||||
|
if (g_ascii_strcasecmp(name, "clickAncestor") == 0) return 7;
|
||||||
|
if (g_ascii_strcasecmp(name, "doDefault") == 0) {
|
||||||
|
if (cthulhu_anchor_has_click_label(source)) return 8;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Some toolkits expose an edit field's label only as a relation, leaving its
|
// Some toolkits expose an edit field's label only as a relation, leaving its
|
||||||
// accessible name empty. Follow direct targets only, with a shared query and
|
// accessible name empty. Follow direct targets only, with a shared query and
|
||||||
// output budget; never infer a label from neighbouring controls or field text.
|
// output budget; never infer a label from neighbouring controls or field text.
|
||||||
@@ -590,22 +640,14 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
|
|||||||
if (error != NULL) {
|
if (error != NULL) {
|
||||||
g_clear_error(&error);
|
g_clear_error(&error);
|
||||||
}
|
}
|
||||||
for (gint index = 0; index < actionCount; index++) {
|
for (gint index = 0; index < MIN(actionCount, 64); index++) {
|
||||||
error = NULL;
|
error = NULL;
|
||||||
gchar *actionName = atspi_action_get_name(action, index, &error);
|
gchar *actionName = atspi_action_get_name(action, index, &error);
|
||||||
if (error != NULL) {
|
if (error != NULL) {
|
||||||
g_clear_error(&error);
|
g_clear_error(&error);
|
||||||
}
|
}
|
||||||
// Web providers use different action names for activation. Keep
|
if (cthulhu_document_action_priority(source, actionName,
|
||||||
// this list aligned with activateNativeDocumentObject so browse
|
g_strcmp0(snapshot.htmlTag, "a") == 0) != 0) {
|
||||||
// Enter can reach objects exposing click-ancestor or press.
|
|
||||||
if (actionName != NULL &&
|
|
||||||
(g_ascii_strcasecmp(actionName, "click") == 0 ||
|
|
||||||
g_ascii_strcasecmp(actionName, "click-ancestor") == 0 ||
|
|
||||||
g_ascii_strcasecmp(actionName, "press") == 0 ||
|
|
||||||
g_ascii_strcasecmp(actionName, "jump") == 0 ||
|
|
||||||
g_ascii_strcasecmp(actionName, "open") == 0 ||
|
|
||||||
g_ascii_strcasecmp(actionName, "activate") == 0)) {
|
|
||||||
snapshot.supportsClick = TRUE;
|
snapshot.supportsClick = TRUE;
|
||||||
g_free(actionName);
|
g_free(actionName);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -109,7 +108,7 @@ func (adapter *Adapter) SetDocumentPosition(ctx context.Context, position Object
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CaptureDocumentSoundGeometry refreshes only the presented object and its
|
// CaptureDocumentSoundGeometry refreshes only the presented object and its
|
||||||
// optional linked heading target, after navigation has scrolled them into view.
|
// optional semantic link target, after navigation has scrolled them into view.
|
||||||
// It never changes the cached document semantics or retains new native handles.
|
// It never changes the cached document semantics or retains new native handles.
|
||||||
func (adapter *Adapter) CaptureDocumentSoundGeometry(ctx context.Context, object, link ObjectReference) ([2]SoundGeometry, error) {
|
func (adapter *Adapter) CaptureDocumentSoundGeometry(ctx context.Context, object, link ObjectReference) ([2]SoundGeometry, error) {
|
||||||
if !object.Valid() {
|
if !object.Valid() {
|
||||||
@@ -231,7 +230,6 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
|
|||||||
return errors.New("current document object has no accessible action")
|
return errors.New("current document object has no accessible action")
|
||||||
}
|
}
|
||||||
defer C.g_object_unref(C.gpointer(action))
|
defer C.g_object_unref(C.gpointer(action))
|
||||||
names := []string{"click", "click-ancestor", "press", "jump", "open", "activate"}
|
|
||||||
var nativeErr *C.GError
|
var nativeErr *C.GError
|
||||||
count := int(C.atspi_action_get_n_actions(action, &nativeErr))
|
count := int(C.atspi_action_get_n_actions(action, &nativeErr))
|
||||||
if nativeErr != nil {
|
if nativeErr != nil {
|
||||||
@@ -241,18 +239,22 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
|
|||||||
}
|
}
|
||||||
// Read each action name once. Previously each preferred spelling caused
|
// Read each action name once. Previously each preferred spelling caused
|
||||||
// another full round of synchronous accessibility calls.
|
// another full round of synchronous accessibility calls.
|
||||||
actionNames := make([]string, min(max(count, 0), 64))
|
priorities := make([]int, min(max(count, 0), 64))
|
||||||
for index := range actionNames {
|
anchor := C.gboolean(0)
|
||||||
|
if snapshot.HTMLTag == "a" {
|
||||||
|
anchor = 1
|
||||||
|
}
|
||||||
|
for index := range priorities {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
name := C.atspi_action_get_name(action, C.gint(index), nil)
|
name := C.atspi_action_get_name(action, C.gint(index), nil)
|
||||||
actionNames[index] = C.GoString(name)
|
priorities[index] = int(C.cthulhu_document_action_priority(source, name, anchor))
|
||||||
C.g_free(C.gpointer(name))
|
C.g_free(C.gpointer(name))
|
||||||
}
|
}
|
||||||
for _, wanted := range names {
|
for wanted := 1; wanted <= 8; wanted++ {
|
||||||
for index, name := range actionNames {
|
for index, priority := range priorities {
|
||||||
if !strings.EqualFold(name, wanted) {
|
if priority != wanted {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ typedef struct {
|
|||||||
gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
|
gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
|
||||||
gchar **tableName, gchar **tablePath,
|
gchar **tableName, gchar **tablePath,
|
||||||
gint *rowIndex, gint *columnIndex, gboolean *guiTable);
|
gint *rowIndex, gint *columnIndex, gboolean *guiTable);
|
||||||
|
gint cthulhu_document_action_priority(AtspiAccessible *source, const gchar *name, gboolean anchor);
|
||||||
cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean captureText,
|
cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean captureText,
|
||||||
gboolean captureCaretCharacter,
|
gboolean captureCaretCharacter,
|
||||||
gboolean captureDocumentMetadata,
|
gboolean captureDocumentMetadata,
|
||||||
|
|||||||
@@ -250,6 +250,14 @@ func captureNativeTree(ctx context.Context, root *C.AtspiAccessible, reviewBusNa
|
|||||||
// A visibility change can reveal children omitted by an earlier review
|
// A visibility change can reveal children omitted by an earlier review
|
||||||
// capture, even when the provider emits no children-changed event.
|
// capture, even when the provider emits no children-changed event.
|
||||||
cachedChildren := known && !cached.childrenInvalid && !cached.childrenUnavailable && (!reviewOnly || !cached.geometryInvalid)
|
cachedChildren := known && !cached.childrenInvalid && !cached.childrenUnavailable && (!reviewOnly || !cached.geometryInvalid)
|
||||||
|
if document && snapshot.Role == "internal frame" {
|
||||||
|
// Chromium can replace a sibling iframe document without an
|
||||||
|
// invalidation for its retained frame object. Refresh this boundary
|
||||||
|
// on recapture so a successful header action cannot leave the old
|
||||||
|
// content document pinned in either our cache or libatspi's cache.
|
||||||
|
cachedChildren = false
|
||||||
|
C.atspi_accessible_clear_cache_single(source)
|
||||||
|
}
|
||||||
childrenUnavailable := false
|
childrenUnavailable := false
|
||||||
for _, id := range cached.children {
|
for _, id := range cached.children {
|
||||||
if cache.sources[id] == nil {
|
if cache.sources[id] == nil {
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ package review
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||||
)
|
)
|
||||||
|
|
||||||
type soundGeometryNavigator struct {
|
type soundGeometryNavigator struct {
|
||||||
@@ -20,13 +23,24 @@ type soundGeometryNavigator struct {
|
|||||||
err error
|
err error
|
||||||
reads int
|
reads int
|
||||||
t *testing.T
|
t *testing.T
|
||||||
|
link atspi.ObjectID
|
||||||
|
offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (navigator *soundGeometryNavigator) SetDocumentCaretPosition(ctx context.Context, object atspi.ObjectReference, focus atspi.ObjectID, offset int) error {
|
||||||
|
navigator.offset = offset
|
||||||
|
return navigator.SetDocumentPosition(ctx, object, focus)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.Context, object, link atspi.ObjectReference) ([2]atspi.SoundGeometry, error) {
|
func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.Context, object, link atspi.ObjectReference) ([2]atspi.SoundGeometry, error) {
|
||||||
navigator.reads++
|
navigator.reads++
|
||||||
select {
|
select {
|
||||||
case placed := <-navigator.calls:
|
case placed := <-navigator.calls:
|
||||||
if placed.position != object || link.ID() != webID("/topic_link") {
|
wantLink := navigator.link
|
||||||
|
if !wantLink.Valid() {
|
||||||
|
wantLink = webID("/topic_link")
|
||||||
|
}
|
||||||
|
if placed.position != object || link.ID() != wantLink {
|
||||||
navigator.t.Fatal("geometry refresh did not follow placement of the selected heading and link")
|
navigator.t.Fatal("geometry refresh did not follow placement of the selected heading and link")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -35,6 +49,75 @@ func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.
|
|||||||
return navigator.geometry, navigator.err
|
return navigator.geometry, navigator.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCaretLinkSoundRefreshKeepsLeafAndOffsets(t *testing.T) {
|
||||||
|
old := atspi.SoundGeometry{ObjectKnown: true, ReferenceKnown: true,
|
||||||
|
Object: atspi.Rectangle{X: 900, Width: 80, Height: 20}, Reference: atspi.Rectangle{Width: 1000, Height: 600}}
|
||||||
|
for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine, CaretNextWord, CaretPreviousWord, CaretDocumentStart, CaretDocumentEnd} {
|
||||||
|
for _, state := range []string{"left", "center", "right", "disabled", "failure", "stale"} {
|
||||||
|
t.Run(fmt.Sprintf("%v/%s", command, state), func(t *testing.T) {
|
||||||
|
nodes := []atspi.TreeNode{
|
||||||
|
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/link")}},
|
||||||
|
{Snapshot: atspi.Snapshot{BusName: webID("/link").BusName, Path: "/link", Role: "link", Name: "Wrapped link", Visited: true, SoundGeometry: old}, Children: []atspi.ObjectID{webID("/label")}},
|
||||||
|
{Snapshot: atspi.Snapshot{BusName: webID("/label").BusName, Path: "/label", Role: "static", Name: "Wrapped link", CharacterCount: 12, SoundGeometry: old}},
|
||||||
|
}
|
||||||
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cursor, _ := NewDocumentCursor(tree)
|
||||||
|
position, err := cursor.SyncFocus(context.Background(), webID("/label"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
owner, _ := tree.DocumentContext(position.Object)
|
||||||
|
position.Offset = 8
|
||||||
|
fresh := old
|
||||||
|
fresh.Object.X = map[string]int32{"left": 0, "center": 460, "right": 920}[state]
|
||||||
|
navigator := &soundGeometryNavigator{fakeDocumentNavigator: newFakeDocumentNavigator(), t: t, link: webID("/link")}
|
||||||
|
want := fresh
|
||||||
|
switch state {
|
||||||
|
case "disabled":
|
||||||
|
want = atspi.SoundGeometry{}
|
||||||
|
case "failure":
|
||||||
|
want = atspi.SoundGeometry{}
|
||||||
|
navigator.err = context.DeadlineExceeded
|
||||||
|
case "stale":
|
||||||
|
want = atspi.SoundGeometry{}
|
||||||
|
tree.Invalidate(webID("/link"))
|
||||||
|
default:
|
||||||
|
navigator.geometry = [2]atspi.SoundGeometry{fresh, fresh}
|
||||||
|
}
|
||||||
|
worker, _ := NewWorker(navigator, time.Second)
|
||||||
|
worker.SyncFocus(webID("/doc"))
|
||||||
|
result := worker.placeDocumentCaret(context.Background(), cursor, Result{
|
||||||
|
Moved: true, DocumentEpoch: worker.DocumentEpoch(), DocumentFocus: webID("/doc"),
|
||||||
|
DocumentPosition: position, DocumentCaretCommand: command, DocumentCaretContext: owner.Ancestors,
|
||||||
|
})
|
||||||
|
if result.Err != nil || result.CaretErr != nil || result.DocumentPosition.Object != position.Object ||
|
||||||
|
result.DocumentPosition.Offset != 8 || navigator.offset != 8 || result.DocumentPosition.Snapshot.Role != "static" {
|
||||||
|
t.Fatalf("sound refresh changed caret ownership: %+v", result)
|
||||||
|
}
|
||||||
|
link := result.DocumentCaretContext[1]
|
||||||
|
if link.SoundGeometry != want || !link.Visited || owner.Ancestors[1].SoundGeometry != old {
|
||||||
|
t.Fatalf("ancestor geometry/semantics: %+v", link)
|
||||||
|
}
|
||||||
|
if state == "failure" && !errors.Is(result.DocumentSoundErr, context.DeadlineExceeded) || state == "stale" && result.DocumentSoundErr == nil {
|
||||||
|
t.Fatalf("missing refresh failure: %v", result.DocumentSoundErr)
|
||||||
|
}
|
||||||
|
options := presentation.Options{EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: state != "disabled"}
|
||||||
|
cues := presentation.SoundCues(link, options)
|
||||||
|
if len(cues) != 1 || cues[0].Name != "link_visited" {
|
||||||
|
t.Fatalf("link cue = %+v", cues)
|
||||||
|
}
|
||||||
|
wantPan := map[string]float64{"left": -1, "right": 1}[state]
|
||||||
|
if math.Abs(cues[0].Pan-wantPan) > 0.001 {
|
||||||
|
t.Fatalf("pan = %g, want %g", cues[0].Pan, wantPan)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDocumentSoundGeometryRefreshPreservesSemanticsAndPlacement(t *testing.T) {
|
func TestDocumentSoundGeometryRefreshPreservesSemanticsAndPlacement(t *testing.T) {
|
||||||
oldGeometry := atspi.SoundGeometry{ObjectKnown: true, Object: atspi.Rectangle{X: 900, Y: 3000, Width: 50, Height: 15}}
|
oldGeometry := atspi.SoundGeometry{ObjectKnown: true, Object: atspi.Rectangle{X: 900, Y: 3000, Width: 50, Height: 15}}
|
||||||
freshGeometry := atspi.SoundGeometry{
|
freshGeometry := atspi.SoundGeometry{
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *Document
|
|||||||
func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
||||||
place := func(result Result) Result {
|
place := func(result Result) Result {
|
||||||
if result.DocumentCaretCommand != 0 {
|
if result.DocumentCaretCommand != 0 {
|
||||||
return worker.placeDocumentCaret(ctx, result)
|
return worker.placeDocumentCaret(ctx, cursor, result)
|
||||||
}
|
}
|
||||||
return worker.placeDocument(ctx, cursor, result)
|
return worker.placeDocument(ctx, cursor, result)
|
||||||
}
|
}
|
||||||
@@ -489,7 +489,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
|||||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Result {
|
func (worker *Worker) placeDocumentCaret(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
||||||
if result.Err != nil || !result.Moved ||
|
if result.Err != nil || !result.Moved ||
|
||||||
!worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
!worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
||||||
return result
|
return result
|
||||||
@@ -512,6 +512,12 @@ func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Res
|
|||||||
nativeOffset = 0
|
nativeOffset = 0
|
||||||
}
|
}
|
||||||
result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, nativeOffset)
|
result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, nativeOffset)
|
||||||
|
if result.CaretErr == nil && worker.CurrentDocumentResult(result) {
|
||||||
|
switch result.DocumentCaretCommand {
|
||||||
|
case CaretNextLine, CaretPreviousLine, CaretNextWord, CaretPreviousWord, CaretDocumentStart, CaretDocumentEnd:
|
||||||
|
result = worker.refreshDocumentSoundGeometry(deadline, cursor, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,17 +761,29 @@ func (worker *Worker) refreshDocumentSoundGeometry(ctx context.Context, cursor *
|
|||||||
// Geometry belongs to this presentation, not the document's initial viewport.
|
// Geometry belongs to this presentation, not the document's initial viewport.
|
||||||
// Failed refreshes must not reuse an old position after scrolling or resizing.
|
// Failed refreshes must not reuse an old position after scrolling or resizing.
|
||||||
result.DocumentPosition.Snapshot.SoundGeometry = atspi.SoundGeometry{}
|
result.DocumentPosition.Snapshot.SoundGeometry = atspi.SoundGeometry{}
|
||||||
link := atspi.ObjectReference{}
|
result.DocumentCaretContext = append([]atspi.Snapshot(nil), result.DocumentCaretContext...)
|
||||||
|
var linkID atspi.ObjectID
|
||||||
|
for index := range result.DocumentCaretContext {
|
||||||
|
ancestor := &result.DocumentCaretContext[index]
|
||||||
|
ancestor.SoundGeometry = atspi.SoundGeometry{}
|
||||||
|
if !linkID.Valid() && ancestor.Role == "link" && ancestor.ID() != result.DocumentPosition.Object.ID() {
|
||||||
|
linkID = ancestor.ID()
|
||||||
|
}
|
||||||
|
}
|
||||||
if previous := result.DocumentPosition.LinkedHeading; previous != nil {
|
if previous := result.DocumentPosition.LinkedHeading; previous != nil {
|
||||||
copy := *previous
|
copy := *previous
|
||||||
copy.SoundGeometry = atspi.SoundGeometry{}
|
copy.SoundGeometry = atspi.SoundGeometry{}
|
||||||
result.DocumentPosition.LinkedHeading = ©
|
result.DocumentPosition.LinkedHeading = ©
|
||||||
|
linkID = copy.ID()
|
||||||
|
}
|
||||||
|
link := atspi.ObjectReference{}
|
||||||
|
if linkID.Valid() {
|
||||||
if cursor == nil {
|
if cursor == nil {
|
||||||
result.DocumentSoundErr = atspi.ErrStaleObject
|
result.DocumentSoundErr = atspi.ErrStaleObject
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
link, err = cursor.tree.Reference(copy.ID())
|
link, err = cursor.tree.Reference(linkID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.DocumentSoundErr = err
|
result.DocumentSoundErr = err
|
||||||
return result
|
return result
|
||||||
@@ -778,6 +796,15 @@ func (worker *Worker) refreshDocumentSoundGeometry(ctx context.Context, cursor *
|
|||||||
if result.DocumentPosition.LinkedHeading != nil {
|
if result.DocumentPosition.LinkedHeading != nil {
|
||||||
result.DocumentPosition.LinkedHeading.SoundGeometry = geometry[1]
|
result.DocumentPosition.LinkedHeading.SoundGeometry = geometry[1]
|
||||||
}
|
}
|
||||||
|
for index := range result.DocumentCaretContext {
|
||||||
|
ancestor := &result.DocumentCaretContext[index]
|
||||||
|
switch ancestor.ID() {
|
||||||
|
case result.DocumentPosition.Object.ID():
|
||||||
|
ancestor.SoundGeometry = geometry[0]
|
||||||
|
case linkID:
|
||||||
|
ancestor.SoundGeometry = geometry[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><meta charset="utf-8"><title>Cthulhu Browser Probe</title>
|
||||||
|
<h1 tabindex="0">Action fixture start</h1>
|
||||||
|
<iframe title="Mode header" srcdoc="<!doctype html><body onload='document.onclick=function(event){if(event.target.closest("a")) parent.chooseMode(event.target.closest("a").id)}'><a id='Basic'>BASIC</a> <a id='Advanced'>ADVANCED</a><p>Plain header text</p></body>"></iframe>
|
||||||
|
<iframe id="content" title="Mode content" srcdoc="<!doctype html><p>Initial mode content</p>"></iframe>
|
||||||
|
<p>Plain body text</p>
|
||||||
|
<div id="custom" onclick="this.setAttribute('aria-label', 'Custom activated')"><span>Custom action</span><button>Nested button</button></div>
|
||||||
|
<a id="inactive">Anchor without action</a>
|
||||||
|
<a id="hidden" hidden onclick="chooseMode('Hidden')">Hidden action</a>
|
||||||
|
<a id="ordinary" href="#destination"><span>Ordinary link</span></a>
|
||||||
|
<p id="destination">Destination</p>
|
||||||
|
<script>
|
||||||
|
function chooseMode(mode) {
|
||||||
|
document.getElementById('content').srcdoc = '<!doctype html><p>' + mode + ' mode content</p>';
|
||||||
|
document.querySelector('h1').setAttribute('aria-label', mode + ' activated');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><meta charset="utf-8"><title>Cthulhu Browser Probe</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 8px; }
|
||||||
|
.row { height: 60px; }
|
||||||
|
a { display: block; position: relative; width: 140px; }
|
||||||
|
.center { left: calc(50% - 70px); }
|
||||||
|
.right { left: calc(100% - 140px); }
|
||||||
|
@media (max-width: 1000px) { main { margin-top: 1000px; } }
|
||||||
|
</style>
|
||||||
|
<h1 tabindex="0">Sound fixture start</h1>
|
||||||
|
<main>
|
||||||
|
<div class="row"><a href="#destination"><span>Left link label</span></a></div>
|
||||||
|
<div class="row"><a class="center" href="#destination"><span>Center link label</span></a></div>
|
||||||
|
<div class="row"><a class="right" href="#destination"><span>Right link label</span></a></div>
|
||||||
|
</main>
|
||||||
|
<p id="destination">Destination</p>
|
||||||
|
</html>
|
||||||
@@ -12,6 +12,8 @@ from native_test_session import run_private_session
|
|||||||
|
|
||||||
|
|
||||||
BROWSER_PROOFS = {
|
BROWSER_PROOFS = {
|
||||||
|
"actions": ("./internal/app", "^TestNativeBrowserActionOwnership$", 35, 40, "browser-actions.html"),
|
||||||
|
"link-sounds": ("./internal/app", "^TestNativeBrowserCaretLinkSounds$", 40, 45, "browser-link-sounds.html"),
|
||||||
"article": ("./internal/atspi", "^TestNativeBrowserArticleContext$", 30, 35, "browser-article.html"),
|
"article": ("./internal/atspi", "^TestNativeBrowserArticleContext$", 30, 35, "browser-article.html"),
|
||||||
"emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"),
|
"emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"),
|
||||||
"structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50),
|
"structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50),
|
||||||
|
|||||||
Reference in new issue
Block a user