From 396efbd7c5e1efcbc85886c876a70965719dda0f Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 18 Oct 2026 22:11:54 -0400 Subject: [PATCH] Version bump and some lable/relation fixes. --- README.md | 8 ++ docs/testing/text-control-labels.md | 32 ++++++ internal/atspi/adapter_capture.c | 52 +++++++++- internal/atspi/adapter_native.h | 2 + internal/atspi/adapter_text_boundary.c | 57 +++++++++++ internal/atspi/document_lines_native_test.go | 29 ++++++ internal/atspi/mouse_review_native.go | 2 +- internal/atspi/tentacle_item_native.go | 2 +- internal/atspi/testdata/text_boundary.c | 98 +++++++++++++++++++ internal/atspi/text_boundary_test.go | 29 ++++++ internal/atspi/text_native.go | 4 +- .../presentation/text_label_native_test.go | 94 ++++++++++++++++++ internal/version/version.go | 2 +- tests/browser-lines.html | 2 + tests/text_label_fixture.py | 69 +++++++++++++ 15 files changed, 474 insertions(+), 8 deletions(-) create mode 100644 docs/testing/text-control-labels.md create mode 100644 internal/atspi/adapter_text_boundary.c create mode 100644 internal/atspi/testdata/text_boundary.c create mode 100644 internal/atspi/text_boundary_test.go create mode 100644 internal/presentation/text_label_native_test.go create mode 100644 tests/text_label_fixture.py diff --git a/README.md b/README.md index fda695d..9edf8e0 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,14 @@ Stale native placement gets one refresh and retry at the same target and offset. Braille routing also recognizes the temporary caret produced by clearing a selection, so it cannot reset the routed reading position. +Reading a final ellipsis avoids the provider's native line-boundary request: +Chromium can calculate a boundary past truncated text and abort the browser. +At that character, Cthulhu reads a bounded logical line using explicit line +breaks; visual wrapping may be combined. Ordinary line, character and word +navigation retain their existing boundaries. The regression checks are +`go test ./internal/atspi -run TestTextBoundaryAvoidsProviderEllipsisAbort` +and `CTHULHU_NATIVE_BROWSER_TESTS=lines python3 tests/native_browser.py`. + Browse-mode arrows and Read All include text inside embedded editors. Reading continues through the editor's paragraphs before reaching the following page content. diff --git a/docs/testing/text-control-labels.md b/docs/testing/text-control-labels.md new file mode 100644 index 0000000..501c0c3 --- /dev/null +++ b/docs/testing/text-control-labels.md @@ -0,0 +1,32 @@ +# Native text-control labels + +Unnamed text controls can expose their labels through AT-SPI `LABELLED_BY` +relationships. Cthulhu uses those labels for text, entry and password-text +snapshots when the control has no accessible name. An existing name takes +precedence. The lookup follows only explicit targets, with a maximum of 16 +targets and 4096 bytes; it does not guess from neighboring widgets or use +the field's value as its label. + +Run the native GTK and presentation regression in a private display and bus: + +```sh +python3 tests/native_test_session.py env CTHULHU_NATIVE_TEXT_LABELS=1 go test ./internal/presentation -run '^TestNativeTextLabelRelations$' -count=1 -timeout=40s -v +go test ./internal/atspi ./internal/presentation ./internal/braille +env CTHULHU_SPEECH_METADATA=1 python3 tests/native_atspi.py +``` + +The native regression covers empty and populated entries with mnemonic labels, +an explicit accessible name differing from its visible label, a password field, +an unlabeled entry beside unrelated labels, and multiple relationship targets. +It checks the captured names and resulting focus speech text. It does not +establish audible speech or physical Braille acceptance. + +For live verification with a rebuilt reader, open I38's Create Desktop Shortcut +dialog. Tab and Shift+Tab through Target and Shortcut name while empty, then +populate both fields and repeat. Each focus announcement should include its +label and current value. Confirm editing and caret navigation still read field +contents normally. Check an explicitly named search field in the application +picker, plus ordinary labeled entries and password fields in another GTK +application. Password contents must remain private. If using Braille, confirm +labels appear and routing still reaches the editable text. Review and test +these changes before submission. diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index 60711d2..f5dad5c 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -8,6 +8,46 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret, static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot); static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining); +// Some native entries leave Name empty and expose their label only through +// LABELLED_BY. Follow explicit relationships, never nearby text or field values. +static gchar *cthulhu_text_control_label(AtspiAccessible *source) { + GArray *relations = atspi_accessible_get_relation_set(source, NULL); + if (relations == NULL) return NULL; + GString *result = g_string_new(""); + guint remaining = 16; + for (guint i = 0; i < relations->len && remaining > 0; i++) { + AtspiRelation *relation = g_array_index(relations, AtspiRelation *, i); + if (atspi_relation_get_relation_type(relation) != ATSPI_RELATION_LABELLED_BY) continue; + gint targets = atspi_relation_get_n_targets(relation); + for (gint j = 0; j < targets && remaining > 0; j++, remaining--) { + AtspiAccessible *label = atspi_relation_get_target(relation, j); + if (label == NULL) continue; + gchar *name = atspi_accessible_get_name(label, NULL); + if ((name == NULL || *name == '\0') && + atspi_accessible_get_role(label, NULL) == ATSPI_ROLE_LABEL) { + AtspiText *text = atspi_accessible_get_text(label); + if (text != NULL) { + gint count = atspi_text_get_character_count(text, NULL); + g_free(name); + name = count > 0 ? atspi_text_get_text(text, 0, MIN(count, 1024), NULL) : NULL; + g_object_unref(text); + } + } + if (name != NULL && *g_strstrip(name) != '\0' && + result->len + strlen(name) + 1 <= 4096) { + if (result->len > 0) g_string_append_c(result, ' '); + g_string_append(result, name); + } + g_free(name); + g_object_unref(label); + } + } + for (guint i = 0; i < relations->len; i++) + g_object_unref(g_array_index(relations, AtspiRelation *, i)); + g_array_unref(relations); + return g_string_free(result, FALSE); +} + // Collect only the selected row. Walking an entire table on every arrow key // would block input in large application tables. gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns, @@ -389,6 +429,12 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt g_error_free(error); error = NULL; } + if (!snapshot.defunct && (snapshot.name == NULL || *snapshot.name == '\0') && + (g_strcmp0(snapshot.role, "text") == 0 || g_strcmp0(snapshot.role, "entry") == 0 || + g_strcmp0(snapshot.role, "password text") == 0)) { + g_free(snapshot.name); + snapshot.name = cthulhu_text_control_label(source); + } cthulhu_capture_menu_checkability(source, &snapshot); if (!snapshot.defunct && (g_strcmp0(snapshot.role, "list") == 0 || g_strcmp0(snapshot.role, "list box") == 0)) { @@ -556,7 +602,7 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret, g_object_unref(text); return NULL; } - AtspiTextRange *range = atspi_text_get_string_at_offset( + AtspiTextRange *range = cthulhu_text_get_string_at_offset( text, caretOffset, ATSPI_TEXT_GRANULARITY_LINE, &error); // AXText.get_line_at_offset retries Chromium's invalid end-of-text // range. A valid empty range is a real blank line and must stay empty. @@ -568,7 +614,7 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret, } if (retry) { g_boxed_free(atspi_text_range_get_type(), range); - range = atspi_text_get_string_at_offset( + range = cthulhu_text_get_string_at_offset( text, caretOffset - 1, ATSPI_TEXT_GRANULARITY_LINE, &error); } } @@ -836,7 +882,7 @@ gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity g_object_unref(text); return NULL; } - AtspiTextRange *range = atspi_text_get_string_at_offset( + AtspiTextRange *range = cthulhu_text_get_string_at_offset( text, caretOffset, unit, &error); g_object_unref(text); if (error != NULL) { diff --git a/internal/atspi/adapter_native.h b/internal/atspi/adapter_native.h index 51961e2..991c969 100644 --- a/internal/atspi/adapter_native.h +++ b/internal/atspi/adapter_native.h @@ -101,6 +101,8 @@ void cthulhu_capture_set_position(AtspiAccessible *source, GHashTable *attribute gboolean cthulhu_has_set_position(const gchar *role); gchar *cthulhu_live_content(AtspiAccessible *source); gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity unit); +AtspiTextRange *cthulhu_text_get_string_at_offset(AtspiText *text, gint offset, + AtspiTextGranularity unit, GError **error); guint start_foreground_timer(gpointer data); const gchar *cthulhu_event_type(AtspiEvent *event); gint cthulhu_event_detail1(AtspiEvent *event); diff --git a/internal/atspi/adapter_text_boundary.c b/internal/atspi/adapter_text_boundary.c new file mode 100644 index 0000000..47fff03 --- /dev/null +++ b/internal/atspi/adapter_text_boundary.c @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "adapter_native.h" +#include + +AtspiTextRange *cthulhu_text_get_string_at_offset(AtspiText *text, gint offset, + AtspiTextGranularity unit, GError **error) { + if (unit != ATSPI_TEXT_GRANULARITY_LINE || offset < 0) { + return atspi_text_get_string_at_offset(text, offset, unit, error); + } + + // Chromium can retain layout boundaries past truncated hypertext. In a + // captured crash, LINE at the final U+2026 (140 of 141 characters) tried + // to take a substring starting at 146 and aborted the entire browser. + // Checking returned boundaries is too late: use bounded GetText for an + // ellipsis before invoking the provider's boundary calculation. + GError *probeError = NULL; + guint character = atspi_text_get_character_at_offset(text, offset, &probeError); + if (probeError != NULL) { + g_propagate_error(error, probeError); + return NULL; + } + if (character != 0x2026) { + return atspi_text_get_string_at_offset(text, offset, unit, error); + } + gint count = atspi_text_get_character_count(text, &probeError); + if (probeError != NULL) { + g_propagate_error(error, probeError); + return NULL; + } + if (count <= 0 || offset != count - 1) { + return atspi_text_get_string_at_offset(text, offset, unit, error); + } + + gint start = MAX(0, offset - 4095); + gchar *content = atspi_text_get_text(text, start, offset + 1, error); + if (content == NULL) return NULL; + if (!g_utf8_validate(content, -1, NULL) || + g_utf8_strlen(content, -1) != offset + 1 - start || + !g_str_has_suffix(content, "\342\200\246")) { + g_free(content); + return NULL; + } + + // Preserve explicit line breaks. A provider's visual wrapping is unsafe + // at this character; retain the bounded logical line and native offsets. + gchar *line = strrchr(content, '\n'); + line = line != NULL ? line + 1 : content; + AtspiTextRange *range = g_new0(AtspiTextRange, 1); + range->start_offset = start + g_utf8_pointer_to_offset(content, line); + range->end_offset = offset + 1; + range->content = g_strdup(line); + g_free(content); + return range; +} diff --git a/internal/atspi/document_lines_native_test.go b/internal/atspi/document_lines_native_test.go index 516d9a5..ae7f37f 100644 --- a/internal/atspi/document_lines_native_test.go +++ b/internal/atspi/document_lines_native_test.go @@ -62,4 +62,33 @@ func TestNativeBrowserLogicalLines(t *testing.T) { t.Fatalf("preserved %s line = %+v, %v", test.name, line, err) } } + for _, name := range []string{"Truncated preview…", "猫🐈…", "…"} { + ref := findNamedReference(t, tree, name) + if !ref.Valid() { + t.Fatalf("ellipsis object %q missing", name) + } + offset := len([]rune(name)) - 1 + for _, unit := range []DocumentCaretUnit{DocumentCaretLine, DocumentCaretBrailleLine, DocumentCaretLineRemainder} { + line, err := adapter.ReadDocumentCaret(ctx, ref, offset, unit) + if err != nil || line.End != offset+1 || !strings.HasSuffix(line.Text, "…") { + t.Fatalf("ellipsis %q unit %d = %+v, %v", name, unit, line, err) + } + } + if err := adapter.SetDocumentCaretPosition(ctx, ref, focus, offset); err != nil { + t.Fatalf("place caret on ellipsis: %v", err) + } + // Drain the actual native caret event, exercising snapshot text, + // character/word capture and logical-line repair before proceeding. + for { + select { + case event := <-adapter.Events(): + if event.Type == CaretEvent && strings.HasSuffix(event.Source.BrailleLine, "…") { + goto caretCaptured + } + case <-ctx.Done(): + t.Fatal("ellipsis caret event missing: ", ctx.Err()) + } + } + caretCaptured: + } } diff --git a/internal/atspi/mouse_review_native.go b/internal/atspi/mouse_review_native.go index e78b61f..0a22ad6 100644 --- a/internal/atspi/mouse_review_native.go +++ b/internal/atspi/mouse_review_native.go @@ -160,7 +160,7 @@ func nativePointText(ctx context.Context, source, window *C.AtspiAccessible, x, } read := func(granularity C.AtspiTextGranularity) (string, int) { var err *C.GError - value := C.atspi_text_get_string_at_offset(text, offset, granularity, &err) + value := C.cthulhu_text_get_string_at_offset(text, offset, granularity, &err) if err != nil { C.g_error_free(err) } diff --git a/internal/atspi/tentacle_item_native.go b/internal/atspi/tentacle_item_native.go index b759f60..c46b937 100644 --- a/internal/atspi/tentacle_item_native.go +++ b/internal/atspi/tentacle_item_native.go @@ -62,7 +62,7 @@ func (adapter *Adapter) nativeTransferItem(ctx context.Context, object ObjectID, offset = 0 } } - line := C.atspi_text_get_string_at_offset(text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, nil) + line := C.cthulhu_text_get_string_at_offset(text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, nil) if line == nil { return TransferItem{}, errors.New("focused line unavailable") } diff --git a/internal/atspi/testdata/text_boundary.c b/internal/atspi/testdata/text_boundary.c new file mode 100644 index 0000000..0095153 --- /dev/null +++ b/internal/atspi/testdata/text_boundary.c @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "adapter_native.h" +#include + +static const gchar *content; +static guint boundaryCalls, characterCalls, textCalls; +static gboolean failProbe, changedText; + +guint atspi_text_get_character_at_offset(AtspiText *text, gint offset, GError **error) { + characterCalls++; + if (failProbe) { + g_set_error_literal(error, g_quark_from_static_string("probe"), 1, "removed text"); + return 0; + } + return offset >= 0 && offset < g_utf8_strlen(content, -1) + ? g_utf8_get_char(g_utf8_offset_to_pointer(content, offset)) : 0; +} + +gint atspi_text_get_character_count(AtspiText *text, GError **error) { + return g_utf8_strlen(content, -1); +} + +gchar *atspi_text_get_text(AtspiText *text, gint start, gint end, GError **error) { + textCalls++; + if (changedText) return g_strdup(""); + g_assert_cmpint(start, >=, 0); + g_assert_cmpint(end - start, <=, 4096); + g_assert_cmpint(end, <=, g_utf8_strlen(content, -1)); + const gchar *first = g_utf8_offset_to_pointer(content, start); + return g_strndup(first, g_utf8_offset_to_pointer(content, end) - first); +} + +AtspiTextRange *atspi_text_get_string_at_offset(AtspiText *text, gint offset, + AtspiTextGranularity unit, GError **error) { + boundaryCalls++; + // Model the verified browser failure: asking for LINE at the final + // ellipsis aborts inside the provider, before it can return a bad range. + if (unit == ATSPI_TEXT_GRANULARITY_LINE && offset >= 0 && + offset == g_utf8_strlen(content, -1) - 1) { + g_assert_cmpuint(g_utf8_get_char(g_utf8_offset_to_pointer(content, offset)), !=, 0x2026); + } + AtspiTextRange *range = g_new0(AtspiTextRange, 1); + range->end_offset = g_utf8_strlen(content, -1); + range->content = g_strdup(content); + return range; +} + +static void assert_range(const gchar *value, gint offset, AtspiTextGranularity unit, + gint start, gint end, const gchar *expected, guint calls) { + content = value; + boundaryCalls = characterCalls = textCalls = 0; + GError *error = NULL; + AtspiTextRange *range = cthulhu_text_get_string_at_offset(NULL, offset, unit, &error); + g_assert_no_error(error); + g_assert_nonnull(range); + g_assert_cmpint(range->start_offset, ==, start); + g_assert_cmpint(range->end_offset, ==, end); + g_assert_cmpstr(range->content, ==, expected); + g_assert_cmpuint(boundaryCalls, ==, calls); + if (unit != ATSPI_TEXT_GRANULARITY_LINE || offset < 0) g_assert_cmpuint(characterCalls, ==, 0); + g_assert_cmpuint(textCalls, ==, calls == 0 ? 1 : 0); + g_free(range->content); + g_free(range); +} + +int main(void) { + assert_range("Truncated text…", 14, ATSPI_TEXT_GRANULARITY_LINE, 0, 15, "Truncated text…", 0); + assert_range("…", 0, ATSPI_TEXT_GRANULARITY_LINE, 0, 1, "…", 0); + assert_range("First\n猫🐈…", 8, ATSPI_TEXT_GRANULARITY_LINE, 6, 9, "猫🐈…", 0); + assert_range("Ordinary text", 12, ATSPI_TEXT_GRANULARITY_LINE, 0, 13, "Ordinary text", 1); + assert_range("An… inside", 2, ATSPI_TEXT_GRANULARITY_LINE, 0, 10, "An… inside", 1); + assert_range("Text…", 4, ATSPI_TEXT_GRANULARITY_CHAR, 0, 5, "Text…", 1); + assert_range("Text…", 4, ATSPI_TEXT_GRANULARITY_WORD, 0, 5, "Text…", 1); + assert_range("Text…", -1, ATSPI_TEXT_GRANULARITY_LINE, 0, 5, "Text…", 1); + gchar *prefix = g_strnfill(5000, 'x'); + gchar *longText = g_strconcat(prefix, "…", NULL); + assert_range(longText, 5000, ATSPI_TEXT_GRANULARITY_LINE, 905, 5001, longText + 905, 0); + g_free(prefix); + g_free(longText); + + content = "Changed…"; + boundaryCalls = 0; + changedText = TRUE; + GError *error = NULL; + g_assert_null(cthulhu_text_get_string_at_offset(NULL, 7, ATSPI_TEXT_GRANULARITY_LINE, &error)); + g_assert_no_error(error); + g_assert_cmpuint(boundaryCalls, ==, 0); + changedText = FALSE; + failProbe = TRUE; + g_assert_null(cthulhu_text_get_string_at_offset(NULL, 7, ATSPI_TEXT_GRANULARITY_LINE, &error)); + g_assert_nonnull(error); + g_assert_cmpuint(boundaryCalls, ==, 0); + g_clear_error(&error); + return 0; +} diff --git a/internal/atspi/text_boundary_test.go b/internal/atspi/text_boundary_test.go new file mode 100644 index 0000000..38a8c75 --- /dev/null +++ b/internal/atspi/text_boundary_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestTextBoundaryAvoidsProviderEllipsisAbort(t *testing.T) { + flags, err := exec.Command("pkg-config", "--cflags", "--libs", "atspi-2").CombinedOutput() + if err != nil { + t.Fatalf("native text dependencies: %v: %s", err, flags) + } + binary := filepath.Join(t.TempDir(), "text-boundary") + args := []string{"-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-I.", + "adapter_text_boundary.c", "testdata/text_boundary.c", "-o", binary} + args = append(args, strings.Fields(string(flags))...) + if output, err := exec.Command("cc", args...).CombinedOutput(); err != nil { + t.Fatalf("compile native boundary regression: %v: %s", err, output) + } + if output, err := exec.Command(binary).CombinedOutput(); err != nil { + t.Fatalf("native boundary regression: %v: %s", err, output) + } +} diff --git a/internal/atspi/text_native.go b/internal/atspi/text_native.go index 58ee8f9..293d28c 100644 --- a/internal/atspi/text_native.go +++ b/internal/atspi/text_native.go @@ -19,7 +19,7 @@ package atspi /* #cgo pkg-config: atspi-2 -#include +#include "adapter_native.h" #include // Bound the Go copy even when an application returns an oversized line. static int review_text_length(const gchar *text, int limit) { @@ -176,7 +176,7 @@ func (source nativeReviewText) line(ctx context.Context, offset int) (TextRange, return TextRange{}, err } var nativeErr *C.GError - line := C.atspi_text_get_string_at_offset(source.text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, &nativeErr) + line := C.cthulhu_text_get_string_at_offset(source.text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, &nativeErr) if line != nil { defer C.g_boxed_free(C.atspi_text_range_get_type(), C.gpointer(line)) } diff --git a/internal/presentation/text_label_native_test.go b/internal/presentation/text_label_native_test.go new file mode 100644 index 0000000..c70f53f --- /dev/null +++ b/internal/presentation/text_label_native_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package presentation + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestNativeTextLabelRelations(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_TEXT_LABELS") != "1" { + t.Skip("requires private GTK/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../tests/text_label_fixture.py") + fixture.Env = append(os.Environ(), "NO_AT_BRIDGE=0", "GTK_MODULES=gail:atk-bridge") + stdin, err := fixture.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := fixture.StdoutPipe() + if err != nil { + t.Fatal(err) + } + fixture.Stderr = os.Stderr + if err := fixture.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); fixture.Wait() }() + if scanner := bufio.NewScanner(stdout); !scanner.Scan() || scanner.Text() != "ready" { + t.Fatal("fixture did not start") + } + var previous atspi.ObjectID + for index, probe := range []struct{ name, value string }{ + {"Destination:", ""}, + {"Display name:", "Example document"}, + {"Preferred name", "Example value"}, + {"Secret:", ""}, + {"", "Unlabeled value"}, + {"Account identifier", ""}, + } { + if _, err := fmt.Fprintln(stdin, index); err != nil { + t.Fatal(err) + } + waitFocus: + for { + select { + case <-ctx.Done(): + t.Fatal("missing entry focus", index) + case event := <-adapter.Events(): + if (event.Type != atspi.FocusEvent && event.Type != atspi.FocusedStateEvent) || !event.Source.Focused { + continue + } + snapshot := event.Source + if snapshot.Role != "text" && snapshot.Role != "password text" { + continue + } + if snapshot.ID() == previous { + continue + } + previous = snapshot.ID() + if snapshot.Name != probe.name { + t.Errorf("entry %d name = %q, want %q", index, snapshot.Name, probe.name) + } + speech := FocusText(snapshot) + for _, part := range []string{probe.name, probe.value} { + if part != "" && !strings.Contains(speech, part) { + t.Errorf("entry %d speech %q omits %q", index, speech, part) + } + } + if strings.Contains(speech, "private fixture value") { + t.Error("password content leaked into speech") + } + break waitFocus + } + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 82bdd76..b6bcbf9 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -21,7 +21,7 @@ import "strings" const Name = "Cthulhu" -const Version = "2026.10.17" +const Version = "2026.10.18" const Channel = "master" var Build = Version diff --git a/tests/browser-lines.html b/tests/browser-lines.html index 3f3b278..ca8e6a7 100644 --- a/tests/browser-lines.html +++ b/tests/browser-lines.html @@ -11,5 +11,7 @@ Second Code text +
Truncated preview…
+

First line
猫🐈…

diff --git a/tests/text_label_fixture.py b/tests/text_label_fixture.py new file mode 100644 index 0000000..e0a4de9 --- /dev/null +++ b/tests/text_label_fixture.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Native label relationships for private Xvfb/AT-SPI tests.""" +import subprocess +import sys + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Atk", "1.0") +gi.require_version("GdkX11", "3.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Atk, Gdk, GdkX11, GLib, Gtk + + +def main(): + window = Gtk.Window(title="Text label probe") + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + window.add(box) + entries = [] + for text, value, explicitName, protected in ( + ("_Destination:", "", "", False), + ("Display _name:", "Example document", "", False), + ("Visible _label:", "Example value", "Preferred name", False), + ("_Secret:", "private fixture value", "", True), + (None, "Unlabeled value", "", False), + ): + entry = Gtk.Entry() + entry.set_text(value) + entry.set_visibility(not protected) + if explicitName: + entry.get_accessible().set_name(explicitName) + if text: + label = Gtk.Label.new_with_mnemonic(text) + label.set_mnemonic_widget(entry) + box.pack_start(label, False, False, 0) + box.pack_start(entry, False, False, 0) + entries.append(entry) + # Explicit multi-label relations also preserve their provider order. + combined = Gtk.Entry() + for text in ("Account", "identifier"): + label = Gtk.Label(label=text) + box.pack_start(label, False, False, 0) + combined.get_accessible().add_relationship(Atk.RelationType.LABELLED_BY, label.get_accessible()) + box.pack_start(combined, False, False, 0) + entries.append(combined) + window.show_all() + Gdk.Display.get_default().sync() + subprocess.run(["xdotool", "windowfocus", str(GdkX11.X11Window.get_xid(window.get_window()))], check=True) + + def command_ready(_source, condition): + if condition & GLib.IO_HUP: + Gtk.main_quit() + return False + line = sys.stdin.readline() + if not line: + Gtk.main_quit() + return False + entries[int(line)].grab_focus() + return True + + GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command_ready) + GLib.idle_add(lambda: (print("ready", flush=True), False)[1]) + Gtk.main() + + +if __name__ == "__main__": + main()