package main import ( "context" "net" "os" "os/exec" "reflect" "strings" "sync" "syscall" "testing" "time" ) // clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history // and copy, requires the X display on its own machine, replaces greenclip, starts its daemon once // from the session's start, and binds its menu as an i3 drop-in through the launcher's dmenu command. func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) { m := readManifest(t) if m.Module != "clipmenu" || m.Seats != nil { t.Fatalf("module %q declares seats %v", m.Module, m.Seats) } if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-clipboard", Scope: "node", Serves: []string{"history", "copy"}}}) { t.Fatalf("claims: %+v", m.Claims) } if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { t.Fatalf("requires: %v", m.Requires) } present, absent := m.packages() if !reflect.DeepEqual(present, []string{"clipmenu"}) || !reflect.DeepEqual(absent, []string{"rofi-greenclip"}) { t.Fatalf("packages: %v, absent %v", present, absent) } } func TestTheDaemonIsTheOperatorsUserUnitEnabledAndRunning(t *testing.T) { m := readManifest(t) if len(m.shell(t)) != 0 { t.Fatalf("a second start from the session: %+v", m.shell(t)) } svc := m.resource(t, "daemon") want := map[string]any{"id": "daemon", "type": "service", "unit": "clipmenud.service", "scope": "user", "user": "${machine:account}", "state": "running", "boot": "enabled", "reload-on": nil} for k, v := range want { if k != "reload-on" && svc[k] != v { t.Fatalf("daemon %s = %v, want %v: %v", k, svc[k], v, svc) } } // ADR 0240 rule 8: a long-running resource says how it is ready. The launcher keeps the unit active while // it waits for the display and execs clipmenud, so the unit's state is the daemon's own liveness. if h, _ := svc["health"].(map[string]any); h["kind"] != "unit" { t.Fatalf("daemon health = %v, want kind unit", svc["health"]) } dropin := m.resource(t, "daemon-dropin") content := m.text(t, "daemon-dropin") if dropin["path"] != "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf" || dropin["owner"] != "${machine:account}" || !strings.Contains(content, "ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session") || strings.Contains(content, "greenclip") { t.Fatalf("drop-in: %v", dropin) } launcher := m.text(t, "launcher") if !strings.Contains(launcher, "DISPLAY") || !strings.Contains(launcher, "/tmp/.X11-unix") || !strings.Contains(launcher, `PATH="/usr/local/lib/mesh-clipmenu:$PATH"`) || !strings.Contains(launcher, "exec clipmenud") { t.Fatalf("launcher: %q", launcher) } // Waits for the display instead of exiting into the start limit; DISPLAY from the manager wins. if !strings.Contains(launcher, "until [ -n \"$DISPLAY\" ]") || !strings.Contains(launcher, "sleep 1") || strings.Contains(launcher, "exit 1") || strings.Contains(launcher, "exit 0") { t.Fatalf("the launcher must wait, not exit: %q", launcher) } if !strings.Contains(content, "StartLimitIntervalSec=0") || !strings.Contains(content, "RestartSec=2s") { t.Fatalf("drop-in lacks the safety net: %q", content) } for k, v := range m.environmentOf(t).Variables { if !strings.Contains(content, "Environment="+k+"="+v) { t.Fatalf("the unit lacks %s=%s", k, v) } } } func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) { m := readManifest(t) if !reflect.DeepEqual(m.Environment.Variables, map[string]string{"CM_SELECTIONS": "clipboard", "CM_MAX_CLIPS": "500", "CM_HISTLENGTH": "15"}) { t.Fatalf("%v", m.Environment.Variables) } if _, set := m.Environment.Variables["CM_LAUNCHER"]; set { t.Fatal("the launcher is clipmenu's default, dmenu: the seat's command") } m.i3LinesAreSource(t, "files/i3/50-clipmenu.conf") if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") { t.Fatalf("%s", c) } } func TestTheToolsAgreeWithTheManifest(t *testing.T) { m := readManifest(t) checkTheToolsAgree(t, m) checkNoSecretsOrInstallationNames(t) } // No file resource carries its text inline (novox/hq ADR 0349 ยง2). func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { readManifest(t).everyFileIsATemplate(t) } // Run for real: DISPLAY from the manager is used at once; several sockets and no DISPLAY wait. func TestTheLauncherUsesTheManagersDisplayAndWaitsOtherwise(t *testing.T) { m := readManifest(t) script := m.text(t, "launcher") dir := t.TempDir() file := dir + "/launcher" // clipmenud and the socket directory are faked: the script's own paths are rewritten. body := strings.ReplaceAll(script, "/tmp/.X11-unix", dir+"/x11") body = strings.Replace(body, "exec clipmenud", `echo "DISPLAY=$DISPLAY"`, 1) if err := os.WriteFile(file, []byte(body), 0o755); err != nil { t.Fatal(err) } os.MkdirAll(dir+"/x11", 0o755) fakeBin := dir + "/bin" os.MkdirAll(fakeBin, 0o755) // the manager's environment: empty until the file appears os.WriteFile(fakeBin+"/systemctl", []byte("#!/bin/sh\n[ -f \""+dir+"/env\" ] && cat \""+dir+"/env\"\nexit 0\n"), 0o755) run := func(env ...string) (string, bool) { ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond) defer cancel() cmd := exec.CommandContext(ctx, "sh", file) cmd.Env = append([]string{"PATH=" + fakeBin + ":" + os.Getenv("PATH"), "HOME=" + dir}, env...) out, _ := cmd.Output() return strings.TrimSpace(string(out)), ctx.Err() == nil } if out, ok := run("DISPLAY=:7"); !ok || out != "DISPLAY=:7" { t.Fatalf("with DISPLAY: %q %v", out, ok) } if _, ok := run(); ok { t.Fatal("it exited with no X server") } for _, n := range []string{"X0", "X1"} { l, _ := net.Listen("unix", dir+"/x11/"+n) defer l.Close() } if _, ok := run(); ok { t.Fatal("it chose one of two servers") } // the session hands the manager its display while the launcher waits go func() { time.Sleep(1200 * time.Millisecond) os.WriteFile(dir+"/env", []byte("DISPLAY=:1\nOTHER=x\n"), 0o644) }() if out, ok := run(); !ok || out != "DISPLAY=:1" { t.Fatalf("the display that arrived while waiting: %q %v", out, ok) } } // Run for real: while another clipmenud of the account holds the session lock the launcher waits without // a word, and starts clipmenud once the lock is free (issue 862). func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T) { if _, err := exec.LookPath("flock"); err != nil { t.Skip("flock is missing here") } m := readManifest(t) script := m.text(t, "launcher") dir := t.TempDir() body := strings.Replace(script, "exec clipmenud", `echo "started"`, 1) file := dir + "/launcher" if err := os.WriteFile(file, []byte(body), 0o755); err != nil { t.Fatal(err) } store := dir + "/cm/clipmenu.6.op" os.MkdirAll(store, 0o700) lock := store + "/session_lock" // the older clipmenud: it holds the lock the way clipmenud does, until it is killed holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60") holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if err := holder.Start(); err != nil { t.Fatal(err) } // flock's child shares the lock, so the whole group goes release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() } defer release() for i := 0; i < 50; i++ { if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil { break } time.Sleep(50 * time.Millisecond) } var out syncBuffer cmd := exec.Command("sh", file) cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "DISPLAY=:7", "USER=op", "CM_DIR=" + dir + "/cm"} cmd.Stdout, cmd.Stderr = &out, &out if err := cmd.Start(); err != nil { t.Fatal(err) } done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case <-done: t.Fatalf("the launcher went on while the lock was held: %q", out.String()) case <-time.After(2500 * time.Millisecond): } if out.Len() != 0 { t.Fatalf("the launcher spoke while it waited: %q", out.String()) } release() select { case <-done: case <-time.After(5 * time.Second): cmd.Process.Kill() t.Fatal("the launcher did not start clipmenud after the lock was freed") } if got := strings.TrimSpace(out.String()); got != "started" { t.Fatalf("after the lock was freed: %q", got) } } type syncBuffer struct { mu sync.Mutex b strings.Builder } func (s *syncBuffer) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() return s.b.Write(p) } func (s *syncBuffer) Len() int { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Len() } func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }