230 lines
8.5 KiB
Go
230 lines
8.5 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(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() }
|