// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) func TestDesktopInputAvailability(t *testing.T) { logger := slog.New(slog.DiscardHandler) for _, kind := range []string{"x11", "wayland"} { t.Run(kind, func(t *testing.T) { t.Setenv("XDG_SESSION_TYPE", kind) t.Setenv("DISPLAY", "") backends := []desktopInputBackend{xorg.New(), xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout)} for _, backend := range backends { service := desktopInputService("test", backend, logger) err := service.Start(context.Background()) if (err == nil) != (kind == "wayland") { t.Fatalf("%T startup in %s: %v", backend, kind, err) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) if err := service.Stop(ctx); err != nil { t.Errorf("%T cleanup: %v", backend, err) } cancel() } // Future non-Xorg backends must keep their own failure contract. failure := errors.New("other backend failed") other := &fakeInputListener{startError: failure} if err := desktopInputService("other", other, logger).Start(context.Background()); !errors.Is(err, failure) { t.Fatalf("other backend startup: %v", err) } }) } } func TestOptionalXorgPreservesCancellation(t *testing.T) { logger := slog.New(slog.DiscardHandler) for _, failure := range []error{context.Canceled, context.DeadlineExceeded} { if err := startOptionalXorg(context.Background(), "test", func(context.Context) error { return failure }, logger); !errors.Is(err, failure) { t.Fatalf("startup: %v, want %v", err, failure) } } ctx, cancel := context.WithCancel(context.Background()) cancel() if err := startOptionalXorg(ctx, "test", func(context.Context) error { return nil }, logger); !errors.Is(err, context.Canceled) { t.Fatalf("canceled startup: %v", err) } } func TestRunWaylandWithoutX11SpeaksAndStops(t *testing.T) { t.Setenv("XDG_SESSION_TYPE", "wayland") t.Setenv("DISPLAY", "") speaker := &fakeSpeaker{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan error, 1) go func() { completed <- Run(ctx, Options{ Logger: slog.New(slog.DiscardHandler), newSpeaker: func() Speaker { return speaker }, newSound: func() soundService { return silentSound{} }, newAdapter: func() Adapter { return adapter }, }) }() adapter.events <- atspi.NewEvent("focus:", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", "")) for _, expected := range []string{"", "Save", "push button"} { select { case spoken := <-speaker.spoken: if expected == "" && contains_string(startupMessages, spoken) || expected != "" && spoken == expected { continue } t.Fatalf("speech = %q, expected %q", spoken, expected) case err := <-completed: t.Fatalf("runtime exited before focus speech: %v", err) case <-time.After(3 * time.Second): t.Fatal("runtime did not present focus with X11 unavailable") } } cancel() select { case err := <-completed: if err != nil { t.Fatal(err) } case <-time.After(3 * time.Second): t.Fatal("runtime did not stop") } if !adapter.stopped || !speaker.closed { t.Fatal("runtime did not clean up AT-SPI and speech") } }