// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) func TestNativeBrowserTabs(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" && os.Getenv("CTHULHU_NATIVE_FIREFOX") != "1" { t.Skip("requires a private browser session") } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) listener := xorg.New() if err := listener.Start(ctx); err != nil { t.Fatal(err) } defer listener.Stop(context.Background()) queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 128)} logFile, err := os.CreateTemp("/tmp", "cthulhu-tabs-events-*.log") if err != nil { t.Fatal(err) } defer logFile.Close() t.Logf("event diagnostics: %s", logFile.Name()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.NewTextHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug})), queue, adapter, nil, nil, 0, nil, listener, nil, nil) }() defer func() { cancel(); <-done }() keys := func(chord string) { t.Helper() if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { t.Fatalf("keys: %v %s", err, out) } } wait := func(role, text string) speechRequest { t.Helper() for { select { case request := <-queue.requests: t.Logf("speech: %s %q (%s, append=%v)", request.Role, request.Text, request.Cause, request.Append) if request.Role == role && (text == "" || request.Text == text || role == "page tab" && strings.Contains(request.Text, text)) { return request } case <-ctx.Done(): t.Fatalf("missing %s %q: %v", role, text, ctx.Err()) return speechRequest{} } } } settle := func(checkTab bool) { t.Helper() // Drain the asynchronous event burst before the next chord so a // duplicate from one operation cannot satisfy the next assertion. timer := time.NewTimer(150 * time.Millisecond) defer timer.Stop() for { select { case request := <-queue.requests: if checkTab && request.Role == "page tab" { t.Fatalf("extra tab announcement without another chord: %#v", request) } if checkTab && (request.Cause == atspi.TextDeletedEvent || request.Cause == atspi.TextInsertedEvent) { t.Fatalf("automatic address rewrite spoken: %#v", request) } timer.Reset(150 * time.Millisecond) case <-timer.C: return case <-ctx.Done(): t.Fatal(ctx.Err()) } } } keys("ctrl+l") // Firefox's address control is exposed as a combo box. role := "entry" if os.Getenv("CTHULHU_NATIVE_FIREFOX") == "1" { role = "combo box" } wait(role, "") settle(false) for i := 0; i < 2; i++ { keys("ctrl+t") request := wait("page tab", "New Tab") if request.Cause != atspi.SelectedStateEvent && request.Cause != atspi.SelectionChangedEvent { t.Fatalf("new tab did not use selected state: %#v", request) } settle(true) } keys("ctrl+shift+Tab") wait("page tab", "New Tab") settle(true) keys("ctrl+w") wait("page tab", "New Tab") settle(true) keys("ctrl+w") wait("page tab", "Cthulhu") settle(true) keys("ctrl+l") keys("x") wait(role, "x") settle(false) keys("BackSpace") wait(role, "x") }