Files
mesh-catalog/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go
T

225 lines
8.4 KiB
Go

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) != 0 {
t.Fatalf("a second start from the session: %+v", m.Shell)
}
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, _ := dropin["content"].(string)
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.resource(t, "launcher")["content"].(string)
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.Environment.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)
}
// 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.resource(t, "launcher")["content"].(string)
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.resource(t, "launcher")["content"].(string)
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() }