//go:build linux
package wayland
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"chirami/internal/platform"
)
var testPNG = append([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, []byte("synthetic-png-payload-for-testing")...)
func newTestBackend(ctx context.Context) *backend {
return &backend{
shots: make(chan []byte, 16),
ctx: ctx,
clipImgCh: make(chan clipImgReq),
clipboardNotify: make(chan struct{}, 1),
}
}
// TestNiriEventParsing_Path verifies that a ScreenshotCaptured event with a valid
// file path reads the file directly and delivers it on Screenshots().
func TestNiriEventParsing_Path(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
sockDir := t.TempDir()
sockPath := filepath.Join(sockDir, "niri.sock")
t.Setenv("NIRI_SOCKET", sockPath)
l, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen unix: %v", err)
}
defer l.Close()
// Write synthetic screenshot file
shotFile := filepath.Join(sockDir, "shot.png")
if err := os.WriteFile(shotFile, testPNG, 0600); err != nil {
t.Fatalf("write shot file: %v", err)
}
b := newTestBackend(ctx)
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
req, err := r.ReadString('\n')
if err != nil || req != "\"EventStream\"\n" {
t.Errorf("server received unexpected request: %q, err: %v", req, err)
return
}
// Send handshake reply
_, _ = conn.Write([]byte("{\"Ok\":\"Handled\"}\n"))
// Send ScreenshotCaptured event with file path
ev := fmt.Sprintf("{\"ScreenshotCaptured\":{\"path\":%q}}\n", shotFile)
_, _ = conn.Write([]byte(ev))
}()
shots := b.Screenshots()
select {
case got := <-shots:
if !bytes.Equal(got, testPNG) {
t.Fatalf("received %q, want %q", got, testPNG)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for screenshot on Screenshots()")
}
}
// TestNiriEventParsing_Clipboard verifies that a ScreenshotCaptured event with path: null
// reads image/png from the clipboard and delivers it on Screenshots().
func TestNiriEventParsing_Clipboard(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
sockDir := t.TempDir()
sockPath := filepath.Join(sockDir, "niri.sock")
t.Setenv("NIRI_SOCKET", sockPath)
l, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen unix: %v", err)
}
defer l.Close()
b := newTestBackend(ctx)
// Mark recent clipboard offer timestamp so it does not wait
b.lastClipOfferNanos.Store(time.Now().UnixNano())
// Mock clipboard responder
go func() {
for {
select {
case <-ctx.Done():
return
case req := <-b.clipImgCh:
req.resp <- testPNG
}
}
}()
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
req, err := r.ReadString('\n')
if err != nil || req != "\"EventStream\"\n" {
return
}
_, _ = conn.Write([]byte("{\"Ok\":\"Handled\"}\n"))
_, _ = conn.Write([]byte("{\"ScreenshotCaptured\":{\"path\":null}}\n"))
}()
shots := b.Screenshots()
select {
case got := <-shots:
if !bytes.Equal(got, testPNG) {
t.Fatalf("received %q, want %q", got, testPNG)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for clipboard screenshot")
}
}
// TestNiriPathFallbackToClipboard verifies that when path is non-existent,
// it falls back to reading image/png from clipboard.
func TestNiriPathFallbackToClipboard(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
sockDir := t.TempDir()
sockPath := filepath.Join(sockDir, "niri.sock")
t.Setenv("NIRI_SOCKET", sockPath)
l, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen unix: %v", err)
}
defer l.Close()
b := newTestBackend(ctx)
b.lastClipOfferNanos.Store(time.Now().UnixNano())
go func() {
for {
select {
case <-ctx.Done():
return
case req := <-b.clipImgCh:
req.resp <- testPNG
}
}
}()
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
_, _ = r.ReadString('\n')
_, _ = conn.Write([]byte("{\"Ok\":\"Handled\"}\n"))
// Non-existent path
_, _ = conn.Write([]byte("{\"ScreenshotCaptured\":{\"path\":\"/tmp/definitely_not_a_screenshot_chirami.png\"}}\n"))
}()
shots := b.Screenshots()
select {
case got := <-shots:
if !bytes.Equal(got, testPNG) {
t.Fatalf("received %q, want %q", got, testPNG)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for fallback clipboard screenshot")
}
}
// TestNiriUnrelatedEvents verifies that other events from niri EventStream
// are safely ignored without error or delivering fake screenshots.
func TestNiriUnrelatedEvents(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
sockDir := t.TempDir()
sockPath := filepath.Join(sockDir, "niri.sock")
t.Setenv("NIRI_SOCKET", sockPath)
l, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen unix: %v", err)
}
defer l.Close()
shotFile := filepath.Join(sockDir, "shot.png")
if err := os.WriteFile(shotFile, testPNG, 0600); err != nil {
t.Fatalf("write shot file: %v", err)
}
b := newTestBackend(ctx)
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
_, _ = r.ReadString('\n')
_, _ = conn.Write([]byte("{\"Ok\":\"Handled\"}\n"))
// Send arbitrary niri events before the screenshot event
unrelated := []string{
"{\"WorkspacesChanged\":{\"workspaces\":[{\"id\":1,\"name\":\"1\",\"is_active\":true}]}}\n",
"{\"WorkspaceUrgencyChanged\":{\"id\":1,\"urgent\":false}}\n",
"{\"WindowOpenedOrChanged\":{\"window\":{\"id\":42,\"title\":\"browser\",\"app_id\":\"zen\"}}}\"\n",
"{\"ConfigLoaded\":{\"failed\":false}}\n",
}
for _, u := range unrelated {
_, _ = conn.Write([]byte(u))
}
// Now send the actual screenshot
ev := fmt.Sprintf("{\"ScreenshotCaptured\":{\"path\":%q}}\n", shotFile)
_, _ = conn.Write([]byte(ev))
}()
shots := b.Screenshots()
select {
case got := <-shots:
if !bytes.Equal(got, testPNG) {
t.Fatalf("received %q, want %q", got, testPNG)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for screenshot after unrelated events")
}
}
// TestNiriReconnect verifies that if niri restarts / drops the IPC connection,
// the watcher reconnects with backoff and resumes receiving events.
func TestNiriReconnect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
sockDir := t.TempDir()
sockPath := filepath.Join(sockDir, "niri.sock")
t.Setenv("NIRI_SOCKET", sockPath)
l, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen unix: %v", err)
}
defer l.Close()
shotFile := filepath.Join(sockDir, "reconnect_shot.png")
if err := os.WriteFile(shotFile, testPNG, 0600); err != nil {
t.Fatalf("write shot file: %v", err)
}
b := newTestBackend(ctx)
go func() {
// Connection 1: handshake and immediately close to simulate restart
conn1, err := l.Accept()
if err != nil {
return
}
r1 := bufio.NewReader(conn1)
_, _ = r1.ReadString('\n')
_, _ = conn1.Write([]byte("{\"Ok\":\"Handled\"}\n"))
_ = conn1.Close()
// Connection 2: handshake and deliver screenshot
conn2, err := l.Accept()
if err != nil {
return
}
defer conn2.Close()
r2 := bufio.NewReader(conn2)
_, _ = r2.ReadString('\n')
_, _ = conn2.Write([]byte("{\"Ok\":\"Handled\"}\n"))
ev := fmt.Sprintf("{\"ScreenshotCaptured\":{\"path\":%q}}\n", shotFile)
_, _ = conn2.Write([]byte(ev))
}()
shots := b.Screenshots()
select {
case got := <-shots:
if !bytes.Equal(got, testPNG) {
t.Fatalf("received %q, want %q", got, testPNG)
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for screenshot after reconnect")
}
}
// TestNiriAbsent verifies that when NIRI_SOCKET is not set, Screenshots()
// simply returns an idle channel and starts no background goroutine.
func TestNiriAbsent(t *testing.T) {
t.Setenv("NIRI_SOCKET", "")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b := newTestBackend(ctx)
ch := b.Screenshots()
if ch == nil {
t.Fatal("Screenshots() returned nil channel")
}
// Verify channel remains empty
select {
case got := <-ch:
t.Fatalf("unexpected screenshot on idle channel: %v", got)
case <-time.After(100 * time.Millisecond):
// Expected: nothing sent
}
}
// TestLiveClipboardImage tests reading a synthetic PNG from the Wayland clipboard via
// data-control selection in a live compositor (headless sway).
// Only runs when TEST_LIVE_WAYLAND=1 is set.
func TestLiveClipboardImage(t *testing.T) {
if os.Getenv("TEST_LIVE_WAYLAND") != "1" {
t.Skip("skipping live wayland clipboard test; set TEST_LIVE_WAYLAND=1 to run")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Start wl-copy in foreground mode to hold synthetic PNG on clipboard
var copyErr bytes.Buffer
copyCmd := exec.CommandContext(ctx, "wl-copy", "-f", "--type", "image/png")
copyCmd.Stdin = bytes.NewReader(testPNG)
copyCmd.Stderr = ©Err
if err := copyCmd.Start(); err != nil {
t.Fatalf("start wl-copy: %v", err)
}
defer func() {
if copyCmd.Process != nil {
_ = copyCmd.Process.Kill()
_ = copyCmd.Wait()
}
}()
// Wait briefly for wl-copy to advertise the offer
time.Sleep(200 * time.Millisecond)
err := Run(func(bp platform.Backend) error {
wb, ok := bp.(*backend)
if !ok {
return errors.New("backend is not *backend")
}
// Read clipboard image using data-control selection with settling retry
img, err := wb.ReadClipboardImageWithRetry(ctx, 3*time.Second)
if err != nil {
return fmt.Errorf("ReadClipboardImage failed: %w", err)
}
if !bytes.Equal(img, testPNG) {
return fmt.Errorf("got %d bytes (%q), want %d bytes (%q)", len(img), img, len(testPNG), testPNG)
}
return nil
})
if err != nil {
t.Fatalf("Run failed: %v", err)
}
}
// An image copied before the screenshot is never taken for it: only a
// selection that arrives around the event is read.
func TestClipboardStaleImageIgnored(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
b := newTestBackend(ctx)
b.lastClipOfferNanos.Store(time.Now().Add(-10 * time.Second).UnixNano())
go func() {
for {
select {
case <-ctx.Done():
return
case req := <-b.clipImgCh:
req.resp <- testPNG
}
}
}()
if _, err := b.ReadClipboardImageWithRetry(ctx, 200*time.Millisecond); err == nil {
t.Fatal("read an image copied before the screenshot")
}
// the screenshot's selection lands a moment after the event
go func() {
time.Sleep(100 * time.Millisecond)
b.lastClipOfferNanos.Store(time.Now().UnixNano())
b.clipboardNotify <- struct{}{}
}()
if _, err := b.ReadClipboardImageWithRetry(ctx, time.Second); err != nil {
t.Fatalf("new selection: %v", err)
}
}