// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "bufio" "context" "fmt" "os" "os/exec" "strings" "testing" "time" ) func TestNativeWebScriptNotificationCapture(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_WEBSCRIPTS") != "1" { t.Skip("requires private Xorg/AT-SPI session") } adapterCtx, adapterCancel := context.WithTimeout(context.Background(), 25*time.Second) defer adapterCancel() adapter := New() if err := adapter.Start(adapterCtx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) for _, processName := range []string{"steamwebhelper", "chromium"} { t.Run(processName, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() command := exec.CommandContext(ctx, "python3", "../../tests/gtk_web_notification.py", processName) input, err := command.StdinPipe() if err != nil { t.Fatal(err) } output, err := command.StdoutPipe() if err != nil { t.Fatal(err) } command.Stderr = os.Stderr if err := command.Start(); err != nil { t.Fatal(err) } defer func() { input.Close(); command.Process.Kill(); command.Wait() }() ready, err := bufio.NewReader(output).ReadString('\n') fields := strings.Fields(ready) if err != nil || len(fields) != 2 || fields[0] != "ready" { t.Fatalf("fixture readiness: %q, %v", ready, err) } // Enumerate the fixture once so GTK publishes its initially hidden // alert accessibles before we request showing/text events. if _, err := adapter.CaptureDesktop(ctx); err != nil { t.Fatal(err) } // Keep the toast window outside foreground ownership. Events must // carry copied notification data without acquiring native focus. if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil { t.Fatalf("foreground focus unavailable: %s, %v", output, err) } fmt.Fprintln(input, "show") if processName == "chromium" { fmt.Fprintln(input, "update") } var root ObjectID for !root.Valid() { select { case event := <-adapter.Events(): if event.Source.ApplicationProcess != processName { continue } if processName == "chromium" { if event.Type != TextInsertedEvent || event.Payload != "Username is now online" { continue } if event.WebNotification != nil { t.Fatal("generic Chromium received Steam metadata") } return } if event.Type != ShowingStateEvent || event.Detail1 == 0 || event.Source.Role != "alert" { continue } message := event.WebNotification if !event.ForegroundMismatch { t.Fatal("toast fixture was not outside foreground ownership") } if message == nil || message.Text != "Username Playing: Example Game" { t.Fatalf("toast text: %+v", message) } root = NewObjectID(message.BusName, message.RootPath) if root != event.Source.ID() { t.Fatal("showing event did not use the alert root") } case <-ctx.Done(): t.Fatal("toast showing event missing") } } fmt.Fprintln(input, "update") for { select { case event := <-adapter.Events(): if event.Source.ApplicationProcess != processName || event.Type != TextInsertedEvent || event.Payload != "Username is now online" { continue } message := event.WebNotification if message == nil || NewObjectID(message.BusName, message.RootPath) != root || !strings.Contains(message.Text, "Username is now online") { t.Fatalf("fragment did not recover complete alert ancestor: %+v", message) } fmt.Fprintln(input, "destroy") for { select { case ending := <-adapter.Events(): if ending.Type == DefunctStateEvent && ending.Source.ID() == root && ending.Detail1 != 0 { fmt.Fprintln(input, "add") for { select { case added := <-adapter.Events(): if added.Type == ChildrenAddedEvent && added.Source.ApplicationProcess == processName && added.WebNotification != nil && added.WebNotification.RootPath != added.Source.Path { if added.WebNotification.Text != "A new toast" { t.Fatalf("child-added did not use the added toast root: %+v", added.WebNotification) } return } case <-ctx.Done(): t.Fatal("added toast did not reach notification handling") } } } case <-ctx.Done(): t.Fatal("destroyed toast root did not reach lifecycle handling") } } case <-ctx.Done(): t.Fatal("toast text insertion missing") } } }) } }