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'''{TITLE} +

{CONTENT[0]}

{CONTENT[1]}

{CONTENT[2]}

+

{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 \n" + "To: Recipient \n" + f"Subject: {TITLE}\nMIME-Version: 1.0\n" + "Content-Type: text/html; charset=UTF-8\n\n" + HTML) + args = [executable, "--no-remote", "--profile", str(profile), "-file", str(fixture)] + config = directory / "settings.toml" + config.write_text('''schema-version = 2 +active-profile = "default" +[profiles.default.speech] +output-module = "main" +[profiles.default.braille] +enabled = false +[profiles.default.sound] +enabled = false +[profiles.default.key-echo] +enabled = false +''') + wmConfig = directory / "i3.config" + wmConfig.write_text(f"font pango:monospace 10\nipc-socket {directory / 'i3.sock'}\n") + server = PreviewSpeechServer(directory / "speech.sock") + processes, report = [], [] + logPath = directory / "reader.log" + + def key(chord): + subprocess.run(["xdotool", "key", "--clearmodifiers", chord], check=True) + + def find_document(): + for app in pyatspi.Registry.getDesktop(0): + if app.name.lower() == application: + for node in walk(app): + if node.getRoleName() == "document web" and node.name == TITLE: + return node + + def check(chord, expected): + offset = len(server.snapshot()) + key(chord) + wait_for(lambda: any(row["text"] == expected + for row in server.snapshot()[offset:]), + f"{application}: {chord} did not speak {expected!r}", timeout=8) + # Normal presentation queues several chunks without SSIP end events. + # The recorder completes the final chunk; earlier chunks can remain open. + wait_for(lambda: server.snapshot()[-1]["finished"], "presentation did not settle") + rows = server.snapshot()[offset:] + actual = [row["text"] for row in rows if row["text"] in CONTENT] + assert actual == [expected], (chord, expected, rows) + assert not any("Could not" in row["text"] for row in rows), (chord, rows) + report.append({"key": chord, "expected": expected, "speech": rows}) + + try: + processes.append(subprocess.Popen(["i3", "-c", str(wmConfig)], + stdout=subprocess.DEVNULL, stderr=(directory / "wm.log").open("w"))) + wait_for(lambda: "0x" in subprocess.check_output( + ["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "private WM missing") + processes.append(subprocess.Popen([ + os.environ["CTHULHU_BINARY"], "--config", str(config), "--no-tentacles", + "--speech-socket", str(directory / "speech.sock"), "--debug-file", str(logPath)], + stdout=subprocess.DEVNULL, stderr=(directory / "reader-stderr.log").open("w"))) + wait_for(lambda: logPath.exists() and "remote D-Bus service ready" in logPath.read_text(), + "private reader missing") + processes.append(subprocess.Popen(args, env=dict(os.environ, MOZ_ACCESSIBILITY_ENABLED="1"), + stdout=subprocess.DEVNULL, stderr=(directory / "application.log").open("w"))) + document = wait_for(find_document, "fixture document missing", timeout=25) + window = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", TITLE], + text=True).splitlines()[0] + subprocess.run(["xdotool", "windowactivate", "--sync", window], check=True) + assert document.queryComponent().grabFocus(), "document refused focus" + wait_for(lambda: document.getState().contains(pyatspi.STATE_FOCUSED), "document not focused") + wait_for(lambda: server.snapshot() and server.snapshot()[-1]["finished"], + "initial focus presentation did not settle") + check("ctrl+Home", CONTENT[0]) + for text in CONTENT[1:]: + check("Down", text) + for text in reversed(CONTENT[:-1]): + check("Up", text) + check("h", CONTENT[2]) + check("Down", CONTENT[3]) + check("shift+h", CONTENT[2]) + check("shift+h", CONTENT[0]) + check("k", CONTENT[4]) + check("ctrl+Home", CONTENT[0]) + offset = len(server.snapshot()) + key("KP_Add") + wait_for(lambda: any(row["text"] == CONTENT[-1] and row["finished"] + for row in server.snapshot()[offset:]), "Read All did not finish", timeout=12) + rows = server.snapshot()[offset:] + assert [row["text"] for row in rows if row["text"] in CONTENT] == CONTENT, rows + assert not any("Could not" in row["text"] for row in rows), rows + report.append({"key": "KP_Add", "speech": rows}) + print(f"PASS: {application} forward/reverse lines, heading-to-arrow, link and Read All", flush=True) + finally: + for process in reversed(processes): + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + server.close() + (directory / "report.json").write_text(json.dumps(report, indent=2)) + (directory / "speech.json").write_text(json.dumps(server.snapshot(), indent=2)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("application", choices=("firefox", "thunderbird", "both"), nargs="?", default="both") + parser.add_argument("--inside", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if not os.environ.get("CTHULHU_BINARY"): + parser.error("Set CTHULHU_BINARY to a freshly built reader") + if args.inside: + run_inside(args.application) + return + applications = ("firefox", "thunderbird") if args.application == "both" else (args.application,) + for application in applications: + directory = Path(tempfile.mkdtemp(prefix=f"cthulhu-{application}-reading-")) + print(f"{application} evidence: {directory}", flush=True) + environment = dict(os.environ, XDG_SESSION_TYPE="x11", CTHULHU_MOZILLA_EVIDENCE=str(directory)) + for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + environment.pop(name, None) + with (directory / "session.log").open("w") as log: + subprocess.run(["xvfb-run", "-a", "-s", "-screen 0 1280x900x24", "dbus-run-session", "--", + sys.executable, str(Path(__file__).resolve()), application, "--inside"], + env=environment, stderr=log, check=True, timeout=120) + + +if __name__ == "__main__": + main() diff --git a/tests/native_thunderbird.py b/tests/native_thunderbird.py new file mode 100644 index 0000000..86cf883 --- /dev/null +++ b/tests/native_thunderbird.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""Local Thunderbird folders, message lists and composition with a private reader.""" +import argparse +from email.message import EmailMessage +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +sys.dont_write_bytecode = True +from native_echo_preview import PreviewSpeechServer +from native_mozilla import walk, write_profile +from native_x11 import wait_for + + +SUBJECTS = ["First test message", "Second test message"] +APPLICATIONS = ("thunderbird", "org.mozilla.thunderbird", "xdg-desktop-portal-gtk") + + +def seed_mailbox(profile, html): + write_profile(profile, "thunderbird") + with (profile / "user.js").open("a") as stream: + stream.write(f'user_pref("mail.identity.id1.compose_html", {str(html).lower()});\n') + stream.write('user_pref("mailnews.default_sort_order", 1);\n') + stream.write('user_pref("mailnews.default_view_flags", 0);\n') + stream.write('user_pref("mail.openMessageBehavior", 2);\n') + stream.write('user_pref("mail.forward_message_mode", 2);\n') + for folder, subjects in {"Inbox": SUBJECTS, "Archive": ["Archived test message"]}.items(): + messages = [] + for index, subject in enumerate(subjects): + message = EmailMessage() + message["From"] = "Sender " + message["To"] = "Username " + message["Cc"] = "Other Recipient " + message["Subject"] = subject + message["Date"] = f"Sat, 17 Oct 2026 12:0{index}:00 +0000" + message["Message-ID"] = f"<{folder}-{index}@example.invalid>" + message.set_content(f"Body of {subject}.\n") + if folder == "Inbox" and index == 0: + message.add_attachment("Synthetic attachment contents.\n", subtype="plain", + filename="test-attachment.txt") + messages.append("From sender@example.invalid Sat Oct 17 12:00:00 2026\n" + + message.as_string() + "\n") + (profile / "Mail" / "Local Folders" / folder).write_text("".join(messages)) + + +def run_inside(html): + import pyatspi + from gi.repository import GLib + directory = Path(os.environ["CTHULHU_THUNDERBIRD_EVIDENCE"]) + executable = shutil.which("thunderbird") + if not executable: + raise RuntimeError("Thunderbird executable missing") + profile = directory / "profile" + seed_mailbox(profile, html) + config = directory / "settings.toml" + config.write_text('''schema-version = 2 +active-profile = "default" +[profiles.default.speech] +output-module = "main" +[profiles.default.braille] +enabled = false +[profiles.default.sound] +enabled = false +[profiles.default.key-echo] +enabled = true +''') + wmConfig = directory / "i3.config" + wmConfig.write_text(f"font pango:monospace 10\nipc-socket {directory / 'i3.sock'}\n") + server = PreviewSpeechServer(directory / "speech.sock") + processes, report = [], [] + logPath = directory / "reader.log" + + def key(chord): + subprocess.run(["xdotool", "key", "--clearmodifiers", chord], check=True) + + def type_text(text): + subprocess.run(["xdotool", "type", "--clearmodifiers", "--delay", "35", "--", text], check=True) + + def find_node(role, name=None, focused=False): + for app in pyatspi.Registry.getDesktop(0): + try: + if app.name.lower() not in APPLICATIONS: + continue + for node in walk(app): + if (node.getRoleName() == role and (name is None or name in node.name) + and node.getState().contains(pyatspi.STATE_SHOWING) + and (not focused or node.getState().contains(pyatspi.STATE_FOCUSED))): + return node + except GLib.Error: + # A closing portal dialog can retire a child during traversal. + # The caller's bounded wait retries against the current tree. + continue + + def expect_focus(role, name=None): + return wait_for(lambda: find_node(role, name, focused=True), + f"native focus missing: {role} {name!r}", timeout=8) + + def check(action, operation, expected, role=None, name=None, exact=False, selected=False): + offset = len(server.snapshot()) + operation() + node = expect_focus(role, name) if role else None + wait_for(lambda: any((expected == row["text"] if exact else expected in row["text"]) + for row in server.snapshot()[offset:]), + f"{action}: missing speech {expected!r}", timeout=8) + wait_for(lambda: server.snapshot()[-1]["finished"], f"{action}: speech did not settle") + rows = server.snapshot()[offset:] + assert not any("Could not" in row["text"] for row in rows), (action, rows) + if node and role in ("tree item", "list item"): + if selected or role == "tree item": + assert node.getState().contains(pyatspi.STATE_SELECTED), (action, "native selection missing") + assert any(row["text"] == role for row in rows), (action, "spoken row role missing", rows) + report.append({"action": action, "expected": expected, + "focus": {"role": node.getRoleName(), "name": node.name} if node else None, + "speech": rows}) + print(f"PASS: {action}", flush=True) + return node + + def read_text(node): + # HTML editors expose their paragraphs as children; plain text can be + # exposed directly on the document. Ignore embedded-object placeholders. + try: + text = node.queryText().getText(0, -1).replace("\ufffc", "") + except NotImplementedError: + text = "" + if text.strip(): + return text + return "\n".join(read_text(child) for child in node) + + def selected_text(node): + for child in walk(node): + try: + text = child.queryText() + if text.getNSelections(): + start, end = text.getSelection(0) + selected = text.getText(start, end).replace("\ufffc", "") + if selected.strip(): + return selected + except NotImplementedError: + continue + return "" + + def selected_messages(): + return [subject for subject in SUBJECTS + if (node := find_node("list item", subject)) is not None + and node.getState().contains(pyatspi.STATE_SELECTED)] + + def discard_composer(): + key("ctrl+w") + discard = wait_for(lambda: find_node("button", "Discard changes"), + "discard confirmation missing") + check("focus discard button", lambda: discard.queryComponent().grabFocus(), + "Discard changes", "button", "Discard changes") + check("discard unsent message", lambda: key("Return"), SUBJECTS[1], + "list item", SUBJECTS[1], selected=True) + + try: + processes.append(subprocess.Popen(["i3", "-c", str(wmConfig)], + stdout=subprocess.DEVNULL, stderr=(directory / "wm.log").open("w"))) + wait_for(lambda: "0x" in subprocess.check_output( + ["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "private WM missing") + processes.append(subprocess.Popen([ + os.environ["CTHULHU_BINARY"], "--config", str(config), "--no-tentacles", + "--speech-socket", str(directory / "speech.sock"), "--debug-file", str(logPath)], + stdout=subprocess.DEVNULL, stderr=(directory / "reader-stderr.log").open("w"))) + wait_for(lambda: logPath.exists() and "remote D-Bus service ready" in logPath.read_text(), + "private reader missing") + processes.append(subprocess.Popen([ + executable, "--no-remote", "--profile", str(profile)], + env=dict(os.environ, MOZ_ACCESSIBILITY_ENABLED="1"), + stdout=subprocess.DEVNULL, stderr=(directory / "application.log").open("w"))) + root = wait_for(lambda: find_node("tree item", "Local Folders"), "folder tree missing", timeout=25) + window = subprocess.check_output([ + "xdotool", "search", "--onlyvisible", "--name", "Local Folders - Mozilla Thunderbird"], + text=True).splitlines()[0] + subprocess.run(["xdotool", "windowactivate", "--sync", window], check=True) + check("focus folder tree", lambda: root.queryComponent().grabFocus(), + "Local Folders", "tree item", "Local Folders") + key("Right") + check("next folder", lambda: key("Down"), "Inbox", "tree item", "Inbox") + check("last folder", lambda: key("End"), "Archive", "tree item", "Archive") + check("first folder", lambda: key("Home"), "Local Folders", "tree item", "Local Folders") + check("return to Inbox", lambda: key("Down"), "Inbox", "tree item", "Inbox") + check("folder to messages", lambda: key("F6"), SUBJECTS[0], "list item", SUBJECTS[0]) + check("next message", lambda: key("Down"), SUBJECTS[1], "list item", SUBJECTS[1], selected=True) + check("previous message", lambda: key("Up"), SUBJECTS[0], "list item", SUBJECTS[0], selected=True) + check("extend message selection", lambda: key("shift+Down"), SUBJECTS[1], + "list item", SUBJECTS[1], selected=True) + wait_for(lambda: selected_messages() == SUBJECTS, "two-message selection missing") + report[-1]["selectedMessages"] = selected_messages() + check("shrink message selection", lambda: key("shift+Up"), SUBJECTS[0], + "list item", SUBJECTS[0], selected=True) + wait_for(lambda: selected_messages() == SUBJECTS[:1], "selection did not shrink") + report[-1]["selectedMessages"] = selected_messages() + check("move without extending selection", lambda: key("ctrl+Down"), SUBJECTS[1], + "list item", SUBJECTS[1]) + wait_for(lambda: selected_messages() == SUBJECTS[:1], "Ctrl+Down changed selection") + check("add focused message to selection", lambda: key("ctrl+space"), "selected", exact=True) + wait_for(lambda: selected_messages() == SUBJECTS, "Ctrl+Space did not add message") + report[-1]["selectedMessages"] = selected_messages() + check("remove focused message from selection", lambda: key("ctrl+space"), "not selected", exact=True) + wait_for(lambda: selected_messages() == SUBJECTS[:1], "Ctrl+Space did not remove message") + report[-1]["selectedMessages"] = selected_messages() + check("return to first selected message", lambda: key("Up"), SUBJECTS[0], + "list item", SUBJECTS[0], selected=True) + check("messages to folders", lambda: key("shift+F6"), "Inbox", "tree item", "Inbox") + check("folders to retained message", lambda: key("F6"), SUBJECTS[0], "list item", SUBJECTS[0]) + + check("open selected message", lambda: key("Return"), SUBJECTS[0]) + check("read opened message", lambda: key("ctrl+Home"), f"Body of {SUBJECTS[0]}.") + check("focus received attachment bar", lambda: key("Tab"), + "test-attachment.txt", "button", "1 attachment") + check("expand received attachments", lambda: key("Return"), "test-attachment.txt", + "list item", "test-attachment", selected=True) + check("received attachment menu", lambda: key("shift+F10"), "menu", "menu") + check("attachment open menu item", lambda: key("Down"), "Open", "menu item", "Open") + check("attachment save menu item", lambda: key("Down"), "Save As", "menu item", "Save As") + check("open attachment save dialog", lambda: key("Return"), "Save Attachment") + key("ctrl+l") + location = expect_focus("text") + savedAttachment = directory / "saved-attachment.txt" + key("ctrl+a") + type_text(str(savedAttachment)) + wait_for(lambda: str(savedAttachment) in location.queryText().getText(0, -1), + "save attachment location missing") + check("save received attachment", lambda: key("Return"), "test-attachment", + "list item", "test-attachment", selected=True) + wait_for(savedAttachment.exists, "attachment was not saved") + assert savedAttachment.read_text().strip() == "Synthetic attachment contents." + check("return to message list", lambda: key("ctrl+w"), SUBJECTS[0], + "list item", SUBJECTS[0], selected=True) + check("next message after return", lambda: key("Down"), SUBJECTS[1], + "list item", SUBJECTS[1], selected=True) + + for action, chord, prefix in (("reply", "ctrl+r", "Re: "), + ("reply all", "ctrl+shift+r", "Re: "), + ("forward", "ctrl+l", "Fwd: ")): + replying = action != "forward" + check(f"open {action}", lambda: key(chord), "Message body" if replying else "To", + "document web" if replying else "entry", + "Message body" if replying else "To") + subject = find_node("entry", "Subject") + assert subject.queryText().getText(0, -1) == prefix + SUBJECTS[1], (action, "subject") + report[-1]["subject"] = subject.queryText().getText(0, -1) + if replying: + assert find_node("section", "Sender : press Enter"), (action, "recipient") + if action == "reply all": + assert find_node("section", "Other Recipient : press Enter"), (action, "Cc") + if action == "forward": + type_text("recipient@example.invalid") + check("commit forward recipient", lambda: key("Tab"), "recipient@example.invalid", + "entry", "To") + check("forward recipient to subject", lambda: key("Tab"), "Subject", "entry", "Subject") + check("forward subject to body", lambda: key("Tab"), "Message body", + "document web", "Message body") + body = expect_focus("document web", "Message body") + wait_for(lambda: f"Body of {SUBJECTS[1]}." in read_text(body), f"{action}: quoted body missing") + originalText = read_text(body) + check(f"{action} character echo", lambda: key("x"), "x", exact=True) + wait_for(lambda: read_text(body) != originalText, f"{action}: typed text missing") + discard_composer() + + check("open composer", lambda: key("ctrl+m"), "To", "entry", "To") + recipient = expect_focus("entry", "To") + type_text("recipient@example.invalid") + wait_for(lambda: recipient.queryText().getText(0, -1) == "recipient@example.invalid", + "recipient text did not reach Thunderbird") + check("commit recipient", lambda: key("Tab"), "recipient@example.invalid", "entry", "To") + subject = check("recipient to subject", lambda: key("Tab"), "Subject", "entry", "Subject") + offset = len(server.snapshot()) + type_text("Draft subject") + wait_for(lambda: subject.queryText().getText(0, -1) == "Draft subject", "subject typing missing") + # Let the title coalescer and SSIP recorder drain after the last key. + # The check must include the final title, not just the typing interval. + time.sleep(0.4) + rows = server.snapshot()[offset:] + assert any(row["text"] == "D" for row in rows), ("subject character echo missing", rows) + assert not any(row["text"].startswith("Write:") for row in rows), ("subject title echo", rows) + report.append({"action": "subject typing without title echo", "speech": rows}) + print("PASS: subject typing without title echo", flush=True) + offset = len(server.snapshot()) + check("subject character deletion", lambda: key("BackSpace"), "t", exact=True) + wait_for(lambda: subject.queryText().getText(0, -1) == "Draft subjec", "subject deletion missing") + time.sleep(0.4) + rows = server.snapshot()[offset:] + assert not any(row["text"].startswith("Write:") for row in rows), ("subject deletion title echo", rows) + type_text("t") + check("subject to body", lambda: key("Tab"), "Message body", "document web", "Message body") + body = expect_focus("document web") + assert body.getState().contains(pyatspi.STATE_EDITABLE), "body is not editable" + check("body character echo", lambda: key("h"), "h", exact=True) + check("body character deletion", lambda: key("BackSpace"), "h", exact=True) + wait_for(lambda: not read_text(body).strip(), "body character deletion missing") + type_text("Draft first line.") + key("Return") + type_text("Draft second line.") + wait_for(lambda: "Draft first line." in read_text(body) and "Draft second line." in read_text(body), + "body text did not reach Thunderbird") + check("body first line", lambda: key("ctrl+Home"), "Draft first line.") + check("body next line", lambda: key("Down"), "Draft second line.") + check("select body word", lambda: (key("Home"), key("ctrl+shift+Right")), "Draft selected", exact=True) + wait_for(lambda: selected_text(body).strip() == "Draft", "native body word selection missing") + check("delete selected word", lambda: key("BackSpace"), "Draft") + wait_for(lambda: "Draft second line." not in read_text(body) and "second line." in read_text(body), + "body selection/deletion did not reach Thunderbird") + check("body back to subject", lambda: key("shift+Tab"), "Subject", "entry", "Subject") + check("subject back to body", lambda: key("Tab"), "Message body", "document web", "Message body") + attachment = directory / "outgoing-attachment.txt" + attachment.write_text("Unsent synthetic attachment.\n") + check("open attachment picker", lambda: key("ctrl+shift+a"), "Attach File") + key("ctrl+l") + location = expect_focus("text") + type_text(str(attachment)) + wait_for(lambda: str(attachment) in location.queryText().getText(0, -1), + "attachment picker location missing") + check("attach local file", lambda: key("Return"), attachment.name, + "list item", attachment.stem, selected=True) + check("clear attachment selection", lambda: key("Escape"), "not selected", exact=True) + wait_for(lambda: not find_node("list item", attachment.stem).getState().contains(pyatspi.STATE_SELECTED), + "attachment selection did not clear") + check("attachment back to body", lambda: key("Escape"), "Message body", + "document web", "Message body") + # No send command is used. Termination below leaves the unsent editor + # in this disposable profile without interacting with a real mailbox. + print(f"PASS: Thunderbird {'HTML' if html else 'plain-text'} local mail workflow", flush=True) + finally: + tree = [] + try: + try: + for app in pyatspi.Registry.getDesktop(0): + if app.name.lower() in APPLICATIONS: + for node in walk(app): + tree.append({"role": node.getRoleName(), "name": node.name, + "showing": node.getState().contains(pyatspi.STATE_SHOWING), + "focused": node.getState().contains(pyatspi.STATE_FOCUSED), + "selected": node.getState().contains(pyatspi.STATE_SELECTED)}) + except Exception as error: + tree.append({"error": str(error)}) + (directory / "tree.json").write_text(json.dumps(tree, indent=2)) + (directory / "report.json").write_text(json.dumps(report, indent=2)) + (directory / "speech.json").write_text(json.dumps(server.snapshot(), indent=2)) + finally: + for process in reversed(processes): + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + server.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--format", choices=("html", "plain", "both"), default="both") + parser.add_argument("--inside", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if not os.environ.get("CTHULHU_BINARY"): + parser.error("Set CTHULHU_BINARY to a freshly built reader") + if args.inside: + run_inside(args.format == "html") + return + formats = ("html", "plain") if args.format == "both" else (args.format,) + for composeFormat in formats: + directory = Path(tempfile.mkdtemp(prefix=f"cthulhu-thunderbird-{composeFormat}-")) + print(f"Thunderbird {composeFormat} evidence: {directory}", flush=True) + environment = dict(os.environ, XDG_SESSION_TYPE="x11", CTHULHU_THUNDERBIRD_EVIDENCE=str(directory)) + for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + environment.pop(name, None) + with (directory / "session.log").open("w") as log: + subprocess.run(["xvfb-run", "-a", "-s", "-screen 0 1280x900x24", "dbus-run-session", "--", + sys.executable, str(Path(__file__).resolve()), "--format", composeFormat, "--inside"], + env=environment, stderr=log, check=True, timeout=120) + + +if __name__ == "__main__": + main()