diff --git a/docs/testing/mozilla-reading.md b/docs/testing/mozilla-reading.md new file mode 100644 index 0000000..ce095c9 --- /dev/null +++ b/docs/testing/mozilla-reading.md @@ -0,0 +1,86 @@ +# Firefox and Thunderbird reading baseline + +The first Mozilla release-readiness slice covers reading an ordinary Firefox +page and a saved HTML message in Thunderbird. Both applications can expose a +heading or link as a text-bearing leaf without a static text child. Document +capture previously recorded zero characters for these roles. Structural heading +navigation still worked, but arrow reading and Read All skipped their content. +Capturing their native text counts fixes this at the shared AT-SPI boundary. +It does not add browser- or application-specific navigation rules. + +## Automated verification + +Build a separate reader and run: + +```sh +go build -o /tmp/cthulhu-mozilla-review ./cmd/cthulhu +python3 tests/native_firefox.py +CTHULHU_BINARY=/tmp/cthulhu-mozilla-review /usr/bin/python3 tests/native_mozilla.py +``` + +The last command requires Firefox, Thunderbird, i3, Xvfb, xdotool, D-Bus and +Python with pyatspi. Pass `firefox` or `thunderbird` to run just one application. +It creates a separate X display, accessibility/session buses, application +profile, reader configuration and speech protocol recorder for each run. +Thunderbird uses a synthetic local identity and a saved message; no mail account +credentials or SMTP server are configured. Existing profiles and the active +desktop reader are not used. + +The evidence directory printed for each application retains the profile, +fixture, reader/application/session logs, `speech.json` and `report.json`. +The test checks: + +- Ctrl+Home reads the initial heading. +- Down and Up traverse all six content items in order, including headings and + the link. +- Heading jumps followed by Down reach the following paragraph. +- Link navigation reaches the expected link. +- Read All emits every content item in order, with heading/link roles. + +The original Firefox metadata test now asserts heading and link character counts +in the captured document. This assertion fails on the original implementation. +It also retains the existing address-bar/page handoff and browser-tab checks. +The reader-level checks passed with Firefox 158.0 and Thunderbird 158.0. Each +run completed 17 navigation commands and Read All; the recorded navigation +results contained no native caret synchronization errors. + +Shared capture changes also need Chromium checks: + +```sh +go test -race ./internal/atspi ./internal/review ./internal/presentation ./internal/app -count=1 -timeout=120s +CTHULHU_NATIVE_BROWSER_TESTS=fragments,linkedheadings,document,editing,selection,modes python3 tests/native_browser.py +``` + +These cover plausible regressions in child-text traversal, linked headings, +structural movement, native editing, selection, Braille routing and focus modes. + +## Human review and live acceptance + +Review the diff and test through the normal rebuilt reader before submission, +as required by the contribution policy. Protocol recordings prove requested +speech, not audible output, physical keyboard behavior or physical Braille. +The Mozilla reader-level harness disables Braille. + +1. Open the retained `reading.html` in Firefox and `message.eml` in Thunderbird. + Move focus into their content. +2. Press Ctrl+Home. Expect "Reading start", heading level 1. Down must read + "First readable paragraph.", "Reading section", "Second readable paragraph.", + "Local destination link" and "Final readable paragraph." in that order. + Up must reverse that order. Check spoken roles and matching Braille. +3. From the first heading, press H and then Down. Expect "Reading section" + followed by "Second readable paragraph.". Check ordinary character and word + movement within a heading and link as well. +4. Return to the start and invoke Read All. Expect every content item, including + both headings and the link. Interrupt it and verify the next navigation + command reads from the expected position. +5. In Firefox, use Ctrl+L and browser-tab switching, then return to the page. + In Thunderbird, move between message headers and body. Verify native controls + retain their keyboard behavior and reading resumes in the correct document. +6. Repeat with representative plain-text and HTML email and ordinary websites. + Check existing Chromium reading, linked headings, editing and selection too. + +This is a reading baseline, not complete Thunderbird acceptance. Folder and +message-list navigation and unsent composition now have their own +[local-mail workflow checks](thunderbird-workflows.md). Settings and real mailbox +workflows still need dedicated acceptance checks. +Page summaries, automatic Read All on load and math support remain separate work. diff --git a/docs/testing/thunderbird-workflows.md b/docs/testing/thunderbird-workflows.md new file mode 100644 index 0000000..e0c7e77 --- /dev/null +++ b/docs/testing/thunderbird-workflows.md @@ -0,0 +1,168 @@ +# Thunderbird local mail and composition + +This slice covers a synthetic local mailbox, folder/message-list navigation, +opening and returning from a message tab, unsent HTML and plain-text +composition, replies/reply-all, inline forwarding, attachment saving/adding, +and multiple message selection. The separate +[Mozilla reading baseline](mozilla-reading.md) covers Firefox pages and saved +Thunderbird messages. Page-load summaries and automatic Read All remain +separate feature work. + +## Changes and boundaries + +Thunderbird can register a GTK accessibility application before replacing it +with its Gecko application while keeping the same X11 client. Previously the +reader could retain the GTK frame and reject real folder/message focus events. +Foreground ownership now accepts a replacement only after fresh active/showing +state, process and geometry checks, and bounded window enumeration. The +candidate's parent application must still be registered on the accessibility +desktop; its current window list must include the candidate and exclude the old +owner. Existing same-process siblings cannot take ownership. Retained focus and +document references belonging to the retired frame are released on the native +owner thread. Unknown or incomplete queries reject replacement. + +The composer keeps native focus on its editable web document while paragraphs +emit text, caret and selection events. Previously those insertions were rejected +as outside the focused editor; subsequent caret events spoke the growing line, +and selection feedback was missing. Native capture now copies the nearest +editable-document identity. Paragraph events belong to that document only while +it owns focus. They use ordinary typing, deletion, selection, clipboard and +Braille presentation. Focus stays on the editor, while the current paragraph +provides text and cursor/routing metadata. Moving to Subject or another editor +revokes the relationship immediately. A nested read-only document is a boundary. + +These are shared AT-SPI and event ownership changes. No Thunderbird-specific +application-name, window-title or widget-label matching selects production +behavior. Orca's Thunderbird editable-message handling and shared web/focus +utilities were used as behavioral references. + +Automatic title changes no longer repeat the focused editor's text during +typing, deletion or clipboard edits when the rest of the title stays the same. +This uses owned editable focus and recent input, with matching title prefix and +suffix. Both AT-SPI and X11 title sources use the same check. Window introductions, +unrelated status changes, empty values and ambiguous matches keep their existing +behavior. There is no application-name or Subject-label exception. The new +recorded-speech assertion reproduced the title echo on the pre-fix reader. + +## Automated checks + +```sh +go build -o /tmp/cthulhu-mozilla-review ./cmd/cthulhu +CTHULHU_BINARY=/tmp/cthulhu-mozilla-review /usr/bin/python3 tests/native_thunderbird.py +``` + +The harness requires Thunderbird, i3, Xvfb, xdotool, D-Bus and Python with +pyatspi. It starts a private reader before Thunderbird, with its own display, +session/accessibility buses, SSIP recorder, profile and configuration. It seeds +Inbox and Archive mbox files, a synthetic local identity, a Cc recipient and a +plain-text MIME attachment. No mail credentials or SMTP server are configured +and no send command is used. Pass `--format html` +or `--format plain` to select one run. + +Each run checks native focus and recorded speech for: + +- Folder Home/End and Down navigation, including returning to Inbox. +- F6/Shift+F6 between the folder tree and message list, retaining the row. +- Up/Down through messages, with native selection and spoken row roles. +- Shift+Down/Shift+Up to extend/shrink message selection; Ctrl+Down to move + without extending it; Ctrl+Space to add/remove the focused message. Native + selected rows must match at each step, and state changes must speak. +- Enter to open the selected message in a tab, Ctrl+Home to read its body, + Ctrl+W to return to the selected row, and Down to the following message. +- Tab from the message body to its attachment bar, Enter to expand the list, + and Shift+F10/Down through Open and Save As. Save the received attachment + into the evidence directory and verify its contents, then return to the + selected message row. +- Ctrl+R/Ctrl+Shift+R/Ctrl+L for reply, reply-all and inline forward, with + expected Subject and quoted body. Replies retain the sender address chip; + reply-all also retains Cc. Forwarding includes recipient entry and Tab to + the body. Type into each body, close it, focus Discard changes and confirm + return to the selected message. These messages are never sent. +- Ctrl+M, recipient typing and committing its address chip, then Subject. +- Subject character echo and Backspace without repeated composer titles. +- Tab from Subject to the editable body; H reaches the editor as typed text. +- Character echo and Backspace, two-line body input, Ctrl+Home/Down reading, + selected-word speech and native text selection, and deletion of that word. +- Shift+Tab/Tab between the body and Subject. +- Ctrl+Shift+A to open the file picker, attach a generated local file, check + the selected attachment row and its speech, Escape to clear selection, and + a second Escape back to the body. + +Initial message-list focus need not select the first row: Thunderbird can expose +a focused but unselected row until a navigation key selects it. The harness +requires selection after Up/Down, rather than treating initial focus as selection. +The parent document can expose a selection as embedded-object placeholders; the +native selection assertion checks the selected paragraph text instead. +The harness ignores hidden duplicate controls in retained tabs and inspects +both Thunderbird accessibility applications and the GTK desktop portal's file +dialogs. Attachment row names can separate the filename and extension with a +space; their descriptions retain the complete filename. File-picker paths and +saved content are checked separately from recorded speech. + +Printed evidence directories retain the profile, mailbox fixtures, settings, +reader/application/session logs, `tree.json`, `speech.json` and `report.json`. +Successful HTML and plain-text runs were verified with Thunderbird 158.0, +with 60 workflow checks in each format. +The tests disable Braille; the event-loop tests separately check paragraph +Braille cursor and routing metadata. + +Run the shared regression checks as well: + +```sh +go test -race ./internal/atspi ./internal/app ./internal/review ./internal/presentation -count=1 -timeout=120s +go vet ./internal/atspi ./internal/app ./internal/review ./internal/presentation +python3 tests/native_firefox.py +python3 tests/check_speech.py +CTHULHU_BINARY=/tmp/cthulhu-mozilla-review /usr/bin/python3 tests/native_mozilla.py +CTHULHU_BINARY=/tmp/cthulhu-mozilla-review /usr/bin/python3 tests/native_ownership.py +CTHULHU_NATIVE_BROWSER_TESTS=editing,selection,modes python3 tests/native_browser.py +``` + +These cover Firefox tabs/address focus and native metadata, Firefox/Thunderbird +reading and Read All, GTK/Qt controls and dialogs, same-process overlapping window +ownership, workspace return, and Chromium editing/selection/focus modes. Passing +the new tests alone does not establish that these existing behaviors still work. + +## Human review and live acceptance + +Review the diff and test the rebuilt reader before submission, under the +[contribution policy](../../CONTRIBUTING.md#ai-assisted-contributions). Recorded +SSIP and synthetic X11 input establish requested behavior, not audible speech, +physical input, a physical Braille display or native Wayland acceptance. + +1. Start Thunderbird with the rebuilt reader already running. In a test mailbox, + use arrows, Home/End, expansion/collapse, F6 and Shift+F6 across folders and + message rows. Check sender, subject, unread/read state and selected rows. +2. Open a message, read its headers and body, return to the list and switch + folders. Check that focus and reading position belong to the current message. +3. Create an unsent HTML message. Type and commit a recipient, Tab to Subject, + and type slowly, pause, then use Backspace and paste text. Confirm ordinary + echo without repeated composer titles. Clear Subject completely; the empty + subject title can still announce. Tab to the body and confirm line/word + movement, selection, Backspace/Delete, cut/paste, and header/body return with + Tab/Shift+Tab. Repeat in plain text. Check speech and Braille text, selection, + cursor and routing. +4. Select several messages with Shift+arrows, move without changing selection + with Ctrl+arrows, and toggle rows with Ctrl+Space. Check announced selection + state and matching Braille, then return to single selection and open a + message. Try a larger folder and nonadjacent selections in a test mailbox. +5. Reply, reply-all and forward a test message without sending it. Verify + recipients, Cc, Subject, quoted content and ordinary body editing. Close the + composer, inspect the save/discard/cancel prompt, cancel once to return to + editing, then discard and verify the original selected message. +6. Tab to a received attachment, expand its list, inspect the context menu, + and save a harmless test file to a chosen directory. Verify the saved file. + Add a local file to an unsent message with Ctrl+Shift+A, check its filename + and selection, then Escape to clear selection and Escape again to return + to editing. Also check removal, multiple + attachments and other file types; those exceed the automated fixture. +7. Move from the body to Subject, another window or another message. Old + body activity must not interrupt the newly focused control. Switch between + multiple Thunderbird windows and ordinary Firefox and Chromium windows. +8. Recheck Firefox tab creation/switch/close and address editing; Chromium + editing and selection; GTK/Qt menus and dialogs; and empty-workspace return. + +Remote accounts, sending, forwarding as an attached message, external attachment +opening, attachment removal, larger/nonadjacent selections, real mailbox +performance and physical speech/Braille remain outside this automated local +baseline. Page-load summaries and automatic Read All remain separate features. diff --git a/internal/app/editable_document_test.go b/internal/app/editable_document_test.go new file mode 100644 index 0000000..d23d386 --- /dev/null +++ b/internal/app/editable_document_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" +) + +func TestEditableDocumentParagraphEvents(t *testing.T) { + queue := &fakeSpeechQueue{spoken: make(chan string, 16)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + editor := atspi.Snapshot{BusName: ":1.7", Path: "/editor", Name: "Message body", Role: "document web", Editable: true} + paragraph := atspi.Snapshot{BusName: editor.BusName, Path: "/paragraph", Role: "paragraph", Editable: true, + EditableRoot: editor.ID(), Text: "a", TextCaretKnown: true, BrailleLine: "a", BrailleCaret: 1} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, editor) + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "a") + // This caret must not reread the growing line after accepted typing. + adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph) + paragraph.SelectionStart, paragraph.SelectionEnd, paragraph.SelectionText = 0, 1, "a" + adapter.events <- atspi.NewEvent(atspi.TextSelectionChangedEvent, paragraph) + adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, paragraph, "a") + subject := atspi.Snapshot{BusName: editor.BusName, Path: "/subject", Name: "Subject", Role: "entry", Editable: true} + adapter.events <- atspi.NewEvent(atspi.FocusEvent, subject) + // A delayed mutation from the old body cannot interrupt a header field. + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "unfocused text") + other := paragraph + other.Path, other.EditableRoot = "/other_paragraph", atspi.NewObjectID(editor.BusName, "/other_editor") + adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, other, "other editor") + adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/end", Role: "button", Name: "End"}) + for _, want := range []string{"Message body, document web", "a", "a selected", "a", "Subject, entry", "End, button"} { + select { + case got := <-queue.spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing %q", want) + } + } +} + +func TestEditableDocumentCaretAndSelectionBraille(t *testing.T) { + root := atspi.NewObjectID(":1.7", "/editor") + frames := make(chan braille.Frame, 2) + state := &eventLoopState{} + state.focusedID = root + handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), brailleOutput: frameRecorder{frames: frames}} + source := atspi.Snapshot{BusName: root.BusName, Path: "/paragraph", Role: "paragraph", Editable: true, + EditableRoot: root, Text: "body", TextCaretKnown: true, BrailleLine: "body", BrailleCaret: 2} + for _, eventType := range []string{atspi.CaretEvent, atspi.TextSelectionChangedEvent} { + handler.acceptFocusedEvent(atspi.NewEvent(eventType, source), recentInput(nil)) + if state.focusedID != root || state.current.ID() != source.ID() || state.brailleSnapshot.ID() != source.ID() { + t.Fatal("paragraph presentation lost native editor focus or Braille ownership") + } + select { + case frame := <-frames: + if !strings.HasPrefix(frame.Text, "body") || frame.Cursor != 2 || len(frame.Routes) == 0 || frame.Routes[0].Path != source.Path { + t.Fatalf("paragraph Braille frame = %+v", frame) + } + default: + t.Fatal("missing paragraph Braille frame") + } + } +} diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index 1dbca73..a3d9bae 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -96,6 +96,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { "prompt_message_present", event.Source.Text != "" && (event.Source.Role == "frame" || event.Source.Role == "alert") || event.WindowMessage != "", "snapshot_duration", event.SnapshotDuration, "editable", event.Source.Editable, + "editable_root", event.Source.EditableRoot.Path, "autocomplete", event.Source.Autocomplete, "autocomplete_popup", event.Source.AutocompletePopup, "event_detail1", event.Detail1, @@ -185,9 +186,14 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { activity = browser.RecentInput() } intent := activity.Intent + if event.Type == atspi.CaretEvent && event.Source.EditableRoot.Valid() && state.focusedID.Valid() && + !belongsToFocusedEditor(event.Source, state.focusedID) { + logger.Debug("skipped caret outside focused editor", "role", event.Source.Role) + return + } if isTextChangeEvent(event.Type) && event.Source.Editable && event.Source.Role != "terminal" && !event.Source.LiveRegion && state.focusedID.Path != "" && - event.Source.ID() != state.focusedID && event.Source.ID() != state.managedDescendantID { + !belongsToFocusedEditor(event.Source, state.focusedID) && event.Source.ID() != state.managedDescendantID { // Settings pages initialize editable number fields while focus // remains in their navigation tree. These are not user edits. logger.Debug("skipped text change outside focused editor", "type", event.Type, "role", event.Source.Role) diff --git a/internal/app/event_filters.go b/internal/app/event_filters.go index b2bc569..cb8433f 100644 --- a/internal/app/event_filters.go +++ b/internal/app/event_filters.go @@ -68,6 +68,12 @@ func speaksTextChanges(snapshot atspi.Snapshot) bool { (snapshot.Editable && !snapshot.ReadOnly && snapshot.Role != "password text") } +func belongsToFocusedEditor(source atspi.Snapshot, focus atspi.ObjectID) bool { + return focus.Path != "" && (source.ID() == focus || focus.Valid() && source.Editable && !source.ReadOnly && + source.Role != "password text" && source.EditableRoot.Valid() && source.EditableRoot == focus && + source.BusName == focus.BusName) +} + func isTextChangeEvent(eventType string) bool { return eventType == atspi.TextInsertedEvent || eventType == atspi.TextDeletedEvent } diff --git a/internal/app/event_focus.go b/internal/app/event_focus.go index 25705aa..53da3b2 100644 --- a/internal/app/event_focus.go +++ b/internal/app/event_focus.go @@ -95,6 +95,13 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event, logger.Debug("skipped selected state already presented by focus", "role", event.Source.Role) return true } + // Keep native focus on the editor, but present the paragraph which owns + // its current caret/selection. Leaving that editor immediately revokes + // this relationship; no extra mutable ownership state is retained. + if belongsToFocusedEditor(event.Source, state.focusedID) && + (isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent || event.Type == atspi.TextSelectionChangedEvent) { + state.current = event.Source + } if isFocusEvent(event.Type) || isManagedDescendantEvent(event.Type) || event.Type == atspi.AccessibleNameEvent && presentsReusedRow(event.Source) && event.Source.ID() == state.managedDescendantID || diff --git a/internal/app/event_text_selection.go b/internal/app/event_text_selection.go index c1b200c..432db3e 100644 --- a/internal/app/event_text_selection.go +++ b/internal/app/event_text_selection.go @@ -29,7 +29,7 @@ func (handler *accessibilityEventHandler) handleSelectionAndClipboard(event atsp return true } if event.Type == atspi.TextSelectionChangedEvent { - if event.Source.ID() != state.focusedID || !event.Source.Editable || event.Source.ReadOnly || + if !belongsToFocusedEditor(event.Source, state.focusedID) || !event.Source.Editable || event.Source.ReadOnly || event.Source.Role == "password text" || event.Source.Autocomplete { return true } @@ -37,6 +37,12 @@ func (handler *accessibilityEventHandler) handleSelectionAndClipboard(event atsp if activity.Selecting && intent == input.LineIntent { fragmentLimit = lineSelectionFragmentLimit } + if event.Source.EditableRoot == state.focusedID && event.Source.ID() != state.focusedID && + state.selection.id != event.Source.ID() { + // Native focus stays on the document. The first selection in a + // newly visited paragraph still needs its selected text spoken. + state.selection.reset(atspi.Snapshot{BusName: event.Source.BusName, Path: event.Source.Path}) + } message := state.selection.changedWithLimit(event.Source, fragmentLimit) if message != "" { state.pending.caret.clear() @@ -50,7 +56,7 @@ func (handler *accessibilityEventHandler) handleSelectionAndClipboard(event atsp return true } if activity.Sequence != 0 && activity.Sequence != state.lastClipboardSequence && - event.Source.ID() == state.focusedID && event.Source.Editable && !event.Source.ReadOnly && + belongsToFocusedEditor(event.Source, state.focusedID) && event.Source.Editable && !event.Source.ReadOnly && !event.Source.Autocomplete && event.Source.Role != "password text" { message := "" if event.Type == atspi.TextDeletedEvent && activity.Clipboard == input.CutAction { diff --git a/internal/app/window_titles.go b/internal/app/window_titles.go index c06ce98..528c396 100644 --- a/internal/app/window_titles.go +++ b/internal/app/window_titles.go @@ -10,6 +10,7 @@ import ( "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/wine" ) @@ -21,6 +22,7 @@ type windowTitleSource interface { type pendingWindowTitle struct { text string + previousText string window atspi.ObjectID epoch uint64 native xorg.WindowTitle @@ -103,6 +105,9 @@ func (titles *windowTitleController) queue(pending pendingWindowTitle) { if titles.pending != nil { // Coalescing must not revive a burst interrupted since its first sample. pending.inputSequence = titles.pending.inputSequence + // Compare the whole burst so an intervening status change cannot + // disappear behind a later title which mirrors an edit. + pending.previousText = titles.pending.previousText } if previous := titles.pending; previous != nil && !previous.changed && previous.epoch == pending.epoch && previous.window == pending.window && previous.native.Window == pending.native.Window && @@ -137,11 +142,13 @@ func (titles *windowTitleController) accessible(event atspi.Event) bool { titles.lastTitle = "" return true } + previousTitle := titles.state.owner.title if !titles.state.owner.changeTitle(event) { return true } titles.queue(pendingWindowTitle{text: strings.TrimSpace(event.Source.Name), - window: titles.state.owner.window, epoch: titles.state.foregroundState.Epoch, + previousText: previousTitle, + window: titles.state.owner.window, epoch: titles.state.foregroundState.Epoch, changed: true, application: titles.application}) return true } @@ -185,7 +192,11 @@ func (titles *windowTitleController) nativeChanged() { latest.Title.TitleWindow == latest.Title.Window && !strings.EqualFold(latest.Title.Application, "xterm") { changed = true } - titles.queue(pendingWindowTitle{text: latest.Title.Title, epoch: latest.Epoch, + previousTitle := previous.Title.Title + if switched { + previousTitle = "" + } + titles.queue(pendingWindowTitle{text: latest.Title.Title, previousText: previousTitle, epoch: latest.Epoch, native: latest.Title, changed: changed}) } @@ -203,6 +214,31 @@ func (titles *windowTitleController) enabled() bool { return titles.live == nil || titles.live.Load().Reading.AutomaticallyReadTitleChanges } +// repeats_focused_edit recognizes titles whose only changed portion mirrors +// the owned entry. Use copied focus and input evidence, never application names +// or field labels. Window introductions and unrelated status changes still speak. +func (titles *windowTitleController) repeats_focused_edit(previousTitle, title string) bool { + current := titles.state.current + if previousTitle == "" || !current.Editable || current.ReadOnly || !current.Focused || + !titles.state.focusedID.Valid() || current.ID() != titles.state.focusedID || + titles.state.owner.focus != current.ID() || current.BusName != titles.state.owner.window.BusName { + return false + } + activity := recentInput(titles.inputSource) + if activity.Intent != input.TypingIntent && activity.Intent != input.DeleteIntent && + activity.Clipboard != input.CutAction && activity.Clipboard != input.PasteAction { + return false + } + text := strings.TrimSpace(current.Text) + if text == "" || strings.Count(title, text) != 1 { + return false + } + start := strings.Index(title, text) + prefix, suffix := title[:start], title[start+len(text):] + return len(previousTitle) >= len(prefix)+len(suffix) && + strings.HasPrefix(previousTitle, prefix) && strings.HasSuffix(previousTitle, suffix) +} + func (titles *windowTitleController) flush() { pending := titles.pending titles.clearPending() @@ -253,6 +289,9 @@ func (titles *windowTitleController) flush() { return } titles.lastTitle = pending.text + if pending.changed && titles.repeats_focused_edit(pending.previousText, pending.text) { + return + } if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence { return } diff --git a/internal/app/window_titles_test.go b/internal/app/window_titles_test.go index ee91500..3c277a7 100644 --- a/internal/app/window_titles_test.go +++ b/internal/app/window_titles_test.go @@ -108,6 +108,89 @@ func TestScheduledTitleInterruptedBeforeSpeechQueue(t *testing.T) { } } +func TestAutomaticTitleDoesNotRepeatFocusedEdit(t *testing.T) { + for _, native := range []bool{false, true} { + t.Run(fmt.Sprintf("native=%v", native), func(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + tracker := &input.IntentTracker{} + titles.inputSource = tracker + entry := atspi.Snapshot{BusName: changed.Source.BusName, Path: "/entry", Role: "entry", Editable: true, Focused: true} + titles.state.current = entry + titles.state.focusedID, titles.state.owner.focus = entry.ID(), entry.ID() + titles.state.owner.title, titles.lastTitle = "Compose: (empty) - Mail", "Compose: (empty) - Mail" + source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: titles.lastTitle}}} + if native { + titles.source, titles.foreground, titles.lastNative = source, source, source.title + } + change := func(text string) { + if native { + source.title.Title.Title = text + titles.nativeChanged() + } else { + changed.Source.Name = text + titles.accessible(changed) + } + titles.flush() + } + for _, step := range []struct{ key, text string }{{"D", "D"}, {"r", "Dr"}, {"BackSpace", "D"}} { + tracker.Observe(input.NewKeyEvent(0, true, step.key)) + titles.state.current.Text = step.text + change("Compose: " + step.text + " - Mail") + } + if len(queue.requests) != 0 { + t.Fatalf("titles repeated the edited text: %+v", queue.requests) + } + // The entry still owns focus and input is still recent. A changed + // status outside the edited value must nevertheless be announced. + change("Compose: D - Mail (saved)") + if len(queue.requests) != 1 || queue.requests[0].Text != "Compose: D - Mail (saved)" { + t.Fatalf("unrelated status title = %+v", queue.requests) + } + }) + } +} + +func TestAutomaticTitleEditFilterRequiresOwnedEdit(t *testing.T) { + for _, name := range []string{"no-input", "navigation", "read-only", "unfocused", "other-focus", "other-window", "empty", "ambiguous", "introduction"} { + t.Run(name, func(t *testing.T) { + titles, queue, changed := titleControllerFixture(t) + tracker := &input.IntentTracker{} + titles.inputSource = tracker + tracker.Observe(input.NewKeyEvent(0, true, "r")) + titles.state.current = atspi.Snapshot{BusName: changed.Source.BusName, Path: "/entry", Role: "entry", Text: "Draft", Editable: true, Focused: true} + titles.state.focusedID, titles.state.owner.focus = titles.state.current.ID(), titles.state.current.ID() + titles.state.owner.title, titles.lastTitle = "Compose: Draf - Mail", "Compose: Draf - Mail" + changed.Source.Name = "Compose: Draft - Mail" + switch name { + case "no-input": + titles.inputSource = nil + case "navigation": + tracker.Observe(input.NewKeyEvent(0, true, "Left")) + case "read-only": + titles.state.current.ReadOnly = true + case "unfocused": + titles.state.current.Focused = false + case "other-focus": + titles.state.owner.focus.Path = "/other" + case "other-window": + titles.state.current.BusName = ":1.99" + titles.state.focusedID, titles.state.owner.focus = titles.state.current.ID(), titles.state.current.ID() + case "empty": + titles.state.current.Text = "" + case "ambiguous": + changed.Source.Name = "Compose: Draft - Draft" + case "introduction": + titles.state.owner.title, titles.lastTitle = "", "" + } + titles.accessible(changed) + titles.flush() + if len(queue.requests) != 1 || queue.requests[0].Text != changed.Source.Name { + t.Fatalf("title without matching edit = %+v", queue.requests) + } + }) + } +} + func TestScheduledNativeIntroductionPreservesSwitchAndRepeatInput(t *testing.T) { titles, queue, _ := titleControllerFixture(t) titles.state.owner.clear() diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index e9e626d..f1baabe 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -852,7 +852,10 @@ gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot) { const gchar *role = snapshot->role; + /* Gecko can expose heading/link text directly without static children. */ if (g_strcmp0(role, "paragraph") != 0 && + g_strcmp0(role, "heading") != 0 && + g_strcmp0(role, "link") != 0 && g_strcmp0(role, "list item") != 0 && g_strcmp0(role, "math") != 0 && g_strcmp0(role, "static") != 0 && diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index fb2b20a..2f1e75b 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -110,6 +110,13 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target * captureCaret = role != C.ATSPI_ROLE_DOCUMENT_WEB && role != C.ATSPI_ROLE_DOCUMENT_FRAME && role != C.ATSPI_ROLE_EMBEDDED } snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load()) + if snapshot.Editable && (eventType == CaretEvent || eventType == TextInsertedEvent || + eventType == TextDeletedEvent || eventType == TextSelectionChangedEvent) { + if editor := C.cthulhu_editable_document(target); editor != nil { + snapshot.EditableRoot = nativeObjectID(editor) + C.g_object_unref(C.gpointer(editor)) + } + } snapshot.ApplicationProcess = adapter.applicationProcess(target) if snapshot.Role == "slider" || snapshot.Role == "combo box" && (snapshot.Name == "Key echo speech synthesizer" || snapshot.Name == "Key echo person") { diff --git a/internal/atspi/adapter_events.c b/internal/atspi/adapter_events.c index 62bfb27..1adf62d 100644 --- a/internal/atspi/adapter_events.c +++ b/internal/atspi/adapter_events.c @@ -544,6 +544,33 @@ AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) { return NULL; } +// Editable documents can retain native focus while their paragraphs emit +// typing/caret/selection events. Stop at the nearest document boundary so an +// unrelated outer editor cannot claim events from a nested read-only document. +AtspiAccessible *cthulhu_editable_document(AtspiAccessible *source) { + AtspiAccessible *current = source != NULL ? g_object_ref(source) : NULL; + for (gint depth = 0; current != NULL && depth < 64; depth++) { + AtspiRole role = atspi_accessible_get_role(current, NULL); + if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) { + AtspiStateSet *states = atspi_accessible_get_state_set(current); + gboolean editable = states != NULL && + atspi_state_set_contains(states, ATSPI_STATE_EDITABLE) && + !atspi_state_set_contains(states, ATSPI_STATE_READ_ONLY) && + !atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT); + if (states != NULL) g_object_unref(states); + if (editable) return current; + g_object_unref(current); + return NULL; + } + if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW || role == ATSPI_ROLE_APPLICATION) break; + AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL); + g_object_unref(current); + current = parent; + } + if (current != NULL) g_object_unref(current); + return NULL; +} + // Chromium can detach an existing document from its frame's AT-SPI parent // chain while returning to a tab. Its document identity remains usable. AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source) { diff --git a/internal/atspi/adapter_foreground_native.go b/internal/atspi/adapter_foreground_native.go index f72b169..6626834 100644 --- a/internal/atspi/adapter_foreground_native.go +++ b/internal/atspi/adapter_foreground_native.go @@ -84,10 +84,9 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID, } matched := foregroundMenuPopupMatches(state, adapter.foregroundWindow, id, pid, focusedMenuItem || C.cthulhu_active_menu_window(window) != 0) - if !matched && check != nil { - check.Reason = "different accessible window from correlated foreground" + if matched { + return true } - return matched } // Same-process maximized windows can have identical bounds. As in // Python canBeActiveWindow(clearCache=True), only correlate a freshly @@ -144,11 +143,104 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID, } return false } + // Mozilla can replace its GTK application root with a Gecko root without + // changing the X11 client. Only inspect its window list after fresh state + // and geometry match. Existing siblings cannot retire the previous owner. + if adapter.foregroundWindow.Valid() && !adapter.replacesForegroundWindow(window, id, state.PID, pid) { + if check != nil { + check.Reason = "different accessible window from correlated foreground" + } + return false + } + if adapter.foregroundWindow.Valid() { + adapter.forgetNativeWindowFocus(adapter.foregroundWindow) + adapter.forgetWindowDocuments(adapter.foregroundWindow) + } adapter.foregroundWindow = id adapter.foregroundRecovery = 0 return true } +func (adapter *Adapter) replacesForegroundWindow(window *C.AtspiAccessible, id ObjectID, foregroundPID, pid int) bool { + if foregroundPID <= 0 || pid != foregroundPID || id.BusName != adapter.foregroundWindow.BusName { + return false + } + application := C.atspi_accessible_get_parent(window, nil) + if application == nil { + return false + } + defer C.g_object_unref(C.gpointer(application)) + if C.atspi_accessible_get_role(application, nil) != C.ATSPI_ROLE_APPLICATION { + return false + } + // A retired GTK bridge can still answer queries after Gecko registers + // its replacement application. Do not let that detached root reclaim + // the client. GetApplication itself can use libatspi's old root cache. + desktop := C.atspi_get_desktop(0) + if desktop == nil { + return false + } + defer C.g_object_unref(C.gpointer(desktop)) + C.atspi_accessible_clear_cache_single(desktop) + var queryError *C.GError + applications := int(C.atspi_accessible_get_child_count(desktop, &queryError)) + if queryError != nil { + C.g_error_free(queryError) + return false + } + if applications <= 0 || applications > 128 { + return false + } + registered := false + for index := 0; index < applications; index++ { + child := C.atspi_accessible_get_child_at_index(desktop, C.gint(index), &queryError) + if queryError != nil { + C.g_error_free(queryError) + } + if child == nil { + return false + } + matches := nativeObjectID(child) == nativeObjectID(application) + C.g_object_unref(C.gpointer(child)) + if queryError != nil { + return false + } + if matches { + registered = true + break + } + } + if !registered { + return false + } + C.atspi_accessible_clear_cache_single(application) + count := int(C.atspi_accessible_get_child_count(application, &queryError)) + if queryError != nil { + C.g_error_free(queryError) + return false + } + if count <= 0 || count > 128 { + return false + } + windows := make([]ObjectID, 0, count) + for index := 0; index < count; index++ { + child := C.atspi_accessible_get_child_at_index(application, C.gint(index), &queryError) + if queryError != nil { + C.g_error_free(queryError) + } + if child == nil { + return false + } + childID := nativeObjectID(child) + C.g_object_unref(C.gpointer(child)) + if queryError != nil || !childID.Valid() { + return false + } + windows = append(windows, childID) + } + return foregroundWindowReplaced(adapter.foregroundWindow, id, windows) +} + // Recovery runs only at foreground transitions, off the event consumer. It // handles returning from an empty workspace when a toolkit retains focus and // emits no new focused-child event. Searches and retries are bounded. diff --git a/internal/atspi/adapter_native.h b/internal/atspi/adapter_native.h index f68d73c..b002209 100644 --- a/internal/atspi/adapter_native.h +++ b/internal/atspi/adapter_native.h @@ -120,6 +120,7 @@ const gchar *cthulhu_accessible_path(AtspiAccessible *source); const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source); AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source); AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source); +AtspiAccessible *cthulhu_editable_document(AtspiAccessible *source); AtspiEventListenerCB cthulhu_event_callback_pointer(void); gboolean cthulhu_request_event_loop_quit(void); gboolean cthulhu_request_tree_capture(gpointer userData); diff --git a/internal/atspi/adapter_references_native.go b/internal/atspi/adapter_references_native.go index cf03843..a9fa585 100644 --- a/internal/atspi/adapter_references_native.go +++ b/internal/atspi/adapter_references_native.go @@ -158,6 +158,26 @@ func (adapter *Adapter) rememberNativeFocus(source *C.AtspiAccessible, id Object adapter.nativeFocus = append(adapter.nativeFocus, nativeFocusReference{id: id, source: owned}) } +// An unregistered bridge window must not remain a focused chrome candidate +// after its replacement has acquired the same X11 client. Other windows keep +// their retained focus references. This runs only on the GLib owner. +func (adapter *Adapter) forgetNativeWindowFocus(windowID ObjectID) { + retained := adapter.nativeFocus[:0] + for _, item := range adapter.nativeFocus { + window := C.cthulhu_window_ancestor(item.source) + retired := nativeObjectID(window) == windowID + if window != nil { + C.g_object_unref(C.gpointer(window)) + } + if retired { + C.g_object_unref(C.gpointer(item.source)) + } else { + retained = append(retained, item) + } + } + adapter.nativeFocus = retained +} + // Keep an exact document-to-frame association across Chromium tab switches. // Some returned tabs temporarily lose their AT-SPI parent chain, although // their document identity and the already correlated frame remain live. diff --git a/internal/atspi/firefox_native_test.go b/internal/atspi/firefox_native_test.go index d20992a..c277948 100644 --- a/internal/atspi/firefox_native_test.go +++ b/internal/atspi/firefox_native_test.go @@ -149,6 +149,12 @@ chromeFocus: if err != nil { t.Fatal(err) } + for _, name := range []string{"Firefox baseline heading", "Firefox baseline link"} { + item, err := page.Resolve(findNamedReference(t, page, name)) + if err != nil || item.CharacterCount != len([]rune(name)) { + t.Errorf("Firefox document text metadata for %q: characters=%d err=%v", name, item.CharacterCount, err) + } + } for _, node := range captured.Nodes { if node.Snapshot.Role == "paragraph" { paragraph = node.Reference diff --git a/internal/atspi/foreground.go b/internal/atspi/foreground.go index 08a2c2f..d15f807 100644 --- a/internal/atspi/foreground.go +++ b/internal/atspi/foreground.go @@ -6,10 +6,19 @@ package atspi import ( "math" + "slices" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) +// The native caller supplies a complete, fresh, bounded application window +// list, and separately checks the candidate's process, state and geometry. +func foregroundWindowReplaced(previous, candidate ObjectID, windows []ObjectID) bool { + return previous.Valid() && candidate.Valid() && previous != candidate && + previous.BusName == candidate.BusName && slices.Contains(windows, candidate) && + !slices.Contains(windows, previous) +} + // Epoch changes with the foreground client, including departure to an empty // workspace. Moving/resizing the same client does not reset reading ownership. type ForegroundState struct { diff --git a/internal/atspi/foreground_test.go b/internal/atspi/foreground_test.go index b673114..d9344c7 100644 --- a/internal/atspi/foreground_test.go +++ b/internal/atspi/foreground_test.go @@ -72,3 +72,29 @@ func TestForegroundMenuPopupCorrelation(t *testing.T) { }) } } + +func TestForegroundWindowReplacement(t *testing.T) { + previous := NewObjectID(":1.40", "/gtk_frame") + candidate := NewObjectID(":1.40", "/gecko_frame") + sibling := NewObjectID(":1.40", "/other_window") + for _, test := range []struct { + name string + previous, candidate ObjectID + windows []ObjectID + want bool + }{ + {"replaced application root", previous, candidate, []ObjectID{candidate, sibling}, true}, + {"existing sibling cannot take ownership", previous, candidate, []ObjectID{previous, candidate}, false}, + {"unregistered candidate", previous, candidate, []ObjectID{sibling}, false}, + {"missing window list", previous, candidate, nil, false}, + {"other provider", previous, NewObjectID(":1.41", "/frame"), []ObjectID{NewObjectID(":1.41", "/frame")}, false}, + {"no previous owner", ObjectID{}, candidate, []ObjectID{candidate}, false}, + {"unchanged owner", candidate, candidate, []ObjectID{candidate}, false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := foregroundWindowReplaced(test.previous, test.candidate, test.windows); got != test.want { + t.Fatalf("replacement = %v, want %v", got, test.want) + } + }) + } +} diff --git a/internal/atspi/model.go b/internal/atspi/model.go index c2aa424..40abfac 100644 --- a/internal/atspi/model.go +++ b/internal/atspi/model.go @@ -86,6 +86,9 @@ type Snapshot struct { Checked bool Defunct bool Editable bool + // EditableRoot identifies the nearest editable web document, copied while + // capturing native events. Paragraph events can belong to its native focus. + EditableRoot ObjectID Expandable bool Expanded bool Indeterminate bool diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 17bdfe7..f24942a 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -59,3 +59,5 @@ steam-shader-progress,Steam shader dialog AT-SPI events,web,Report Steam Vulkan wayland-shared-keyboard-access,src/cthulhu/input_event_manager.py,input,Monitor keys and grab reader shortcuts through the shared accessibility keyboard interface,Storm Dragon,internal/platform/wayland internal/app internal/sessionbus,in-progress,Private dbus-run-session race tests cover wire protocol runtime dispatch configuration rollback secure transitions and cleanup; go test ./... -count=1; python3 tests/check_speech.py,Existing native_x11.py keyboard harness passed; browser tabs and editing passed; browser modes passed on isolated rerun after one failure whose cause was not established.,Shared D-Bus fixture passed without an X display. Live niri and physical key suppression remain unverified.,Pending live listening on Wayland,Pending physical Braille on Wayland,No compositor-specific code. See WAYLAND_SUPPORT.md for supported commands limitations and live acceptance. X11 production listeners are unchanged; the suspension probe now links its existing xkbcommon dependency. web-browse-recovery-activation,Orca src/orca/scripts/web/script_utilities.py; master:src/cthulhu/scripts/web/script.py,web,Recover dynamic browse positions and activate custom clickables silently,,internal/atspi internal/review internal/app,in-progress,go test -race ./... -count=1; python3 tests/native_browser.py; python3 tests/native_browser_navigation.py; python3 tests/native_firefox.py,Private Chromium and Firefox fixtures; real-reader changing-page and activation checks use private Xvfb and D-Bus.,,Recorded SSIP checks verify page-only activation feedback; audible listening remains pending.,Selection-collapse regression and native mode frames covered; physical routing remains pending.,See docs/testing/browser-release-review.md for findings and live acceptance. No full Orca parity claim. web-structural-lists,Orca src/orca/structural_navigator.py; src/orca/structural_navigator_command_definitions.py,web,List browser elements and move to a selected item,,internal/input internal/review internal/interaction internal/app,in-progress,"go test -race ./... -count=1; CTHULHU_NATIVE_BROWSER_TESTS=lists,keys,document,modes,editing python3 tests/native_browser.py; python3 tests/native_internal_ui.py",Private Brave/Xvfb list workflow covers 302 links hidden content current row selection Escape clickables heading level and empty list.,,Live listening pending.,Shared internal UI frames covered; physical Braille acceptance pending.,25 browse shortcuts use Alt+Shift. All Orca list types are registered including unbound annotations internal frames and math. Exact targets and page ownership are validated before selection. See docs/testing/browser-lists.md for manual review and regression checks. +mozilla-leaf-text-reading,Orca src/orca/scripts/web/script_utilities.py,web,Read native heading and link text leaves in Firefox and Thunderbird,,internal/atspi tests/native_mozilla.py,in-progress,python3 tests/native_firefox.py; CTHULHU_BINARY=/tmp/cthulhu-mozilla-review /usr/bin/python3 tests/native_mozilla.py; go test -race ./internal/atspi ./internal/review ./internal/presentation ./internal/app -count=1 -timeout=120s,Private Firefox 158.0 and Thunderbird 158.0 sessions each cover 17 navigation commands and Read All with zero native caret synchronization errors.,,Recorded SSIP includes both headings and the link in forward/reverse reading and Read All; audible listening pending.,Shared Chromium selection and routing checks passed; physical Mozilla Braille acceptance pending.,Shared document text metrics now include heading and link roles. Native Firefox metadata assertion failed before the fix. See docs/testing/mozilla-reading.md. Thunderbird folders message lists and composition remain outside this reading baseline. +thunderbird-local-mail-composition,Orca src/orca/scripts/apps/Thunderbird/script.py;src/orca/script_utilities.py,application,Navigate local folders and message tabs; select messages; compose replies and forwards; save and add attachments,,internal/atspi internal/app tests/native_thunderbird.py,in-progress,go test -race ./internal/atspi ./internal/app ./internal/review ./internal/presentation -count=1 -timeout=120s; CTHULHU_BINARY=/tmp/cthulhu-mozilla-review /usr/bin/python3 tests/native_thunderbird.py,Private Thunderbird 158.0 HTML and plain-text local mailbox checks cover message-tab return; Subject and body editing; reply and reply-all recipients with Cc; inline forwarding; exact selected rows after Shift and Ctrl navigation; received attachment saving with content verification; and adding a local file to an unsent draft.,,SSIP verifies focus handoff and row roles; selection toggling; Subject and body echo; reply and forward editing; attachment menus and picker return; audible acceptance pending.,Event-loop regression checks paragraph cursor and routing metadata; physical display acceptance pending.,Foreground replacement requires a registered application and fresh complete window membership. Editable paragraph events belong only to their focused editable document. Title echoes require owned editable focus and recent editing plus unchanged title prefix/suffix; unrelated status titles still announce. See docs/testing/thunderbird-workflows.md; no sending remote mailbox or page-load-option acceptance claimed. diff --git a/tests/native_mozilla.py b/tests/native_mozilla.py new file mode 100644 index 0000000..ebc538c --- /dev/null +++ b/tests/native_mozilla.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Firefox/Thunderbird reading with a private reader, X11, profile and SSIP.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +sys.dont_write_bytecode = True +from native_echo_preview import PreviewSpeechServer +from native_x11 import wait_for + + +TITLE = "Cthulhu Mozilla Reading Probe" +CONTENT = ["Reading start", "First readable paragraph.", "Reading section", + "Second readable paragraph.", "Local destination link", "Final readable paragraph."] +HTML = f'''
{CONTENT[1]}
{CONTENT[3]}
{CONTENT[4]} +{CONTENT[5]}
''' + + +def write_profile(profile, application): + profile.mkdir() + prefs = {"browser.startup.homepage_override.mstone": "ignore", + "datareporting.policy.firstRunURL": "", + "browser.shell.checkDefaultBrowser": False, + "browser.startup.page": 0, + "browser.newtabpage.enabled": False, + "datareporting.policy.dataSubmissionEnabled": False} + if application == "thunderbird": + (profile / "Mail" / "Local Folders").mkdir(parents=True) + prefs.update({"mail.shell.checkDefaultClient": False, + "mailnews.start_page.enabled": False, + "mail.provider.enabled": False, + "mail.rights.override": True, + "mailnews.database.global.indexer.enabled": False, + "mail.accountmanager.accounts": "account1", + "mail.accountmanager.defaultaccount": "account1", + "mail.accountmanager.localfoldersserver": "server1", + "mail.account.account1.server": "server1", + "mail.account.account1.identities": "id1", + "mail.server.server1.type": "none", + "mail.server.server1.hostname": "Local Folders", + "mail.server.server1.userName": "nobody", + "mail.server.server1.name": "Local Folders", + "mail.server.server1.directory": str(profile / "Mail" / "Local Folders"), + "mail.server.server1.directory-rel": "[ProfD]Mail/Local Folders", + "mail.identity.id1.fullName": "Username", + "mail.identity.id1.useremail": "username@example.invalid", + "mail.identity.id1.valid": True, + "mail.compose.autosave": False}) + (profile / "user.js").write_text("".join( + f"user_pref({json.dumps(key)}, {json.dumps(value)});\n" for key, value in prefs.items())) + + +def walk(node): + if node is not None: + yield node + for child in node: + yield from walk(child) + + +def run_inside(application): + import pyatspi + directory = Path(os.environ["CTHULHU_MOZILLA_EVIDENCE"]) + executable = shutil.which(application) + if not executable: + raise RuntimeError(f"{application} executable missing") + profile = directory / "profile" + write_profile(profile, application) + fixture = directory / "reading.html" + fixture.write_text(HTML) + args = [executable, "--no-remote", "--profile", str(profile), "--new-window", fixture.as_uri()] + if application == "thunderbird": + fixture = directory / "message.eml" + fixture.write_text("From: Username