Slack comes from the AUR and Toolbox from JetBrains' self-updating tarball, so neither is declared: the host installs official packages only, and a pinned archive would fight Toolbox's own updates. Slack's start and the operator's i3 window rules become one node-display-session contribution, because Slack's own launch-on-login is a symlink in ~/.config/autostart. Toolbox keeps its own autostart entry as its one start. The shared desktop.go header now names all four bundles.
203 lines
7.2 KiB
Go
203 lines
7.2 KiB
Go
package main
|
|
|
|
// The fake machine the tests run against, and the tests of desktop.go. The same in the
|
|
// nextcloud-client, blueman, slack and jetbrains-toolbox bundles.
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const testHome = "/home/operator"
|
|
|
|
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
|
type fake struct {
|
|
*Machine
|
|
t *testing.T
|
|
mu sync.Mutex
|
|
calls []string
|
|
answer func(name string, args []string) Output
|
|
// onStart is run when systemd-run starts something, to let a fake process appear.
|
|
onStart func(argv []string)
|
|
// stubborn pids ignore SIGTERM.
|
|
stubborn map[int]bool
|
|
signals []string
|
|
}
|
|
|
|
func newFake(t *testing.T) *fake {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
f := &fake{t: t, stubborn: map[int]bool{}}
|
|
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
|
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
|
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
|
f.mu.Lock()
|
|
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
|
f.mu.Unlock()
|
|
if name == "systemd-run" && f.onStart != nil {
|
|
for i, a := range args {
|
|
if a == "--" {
|
|
f.onStart(args[i+1:])
|
|
}
|
|
}
|
|
}
|
|
if f.answer != nil {
|
|
return f.answer(name, args)
|
|
}
|
|
return Output{}
|
|
}
|
|
f.Kill = func(pid int, sig syscall.Signal) error {
|
|
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
|
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
|
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
|
}
|
|
return nil
|
|
}
|
|
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
|
return f
|
|
}
|
|
|
|
func (f *fake) write(path, content string) {
|
|
f.t.Helper()
|
|
p := filepath.Join(f.Root, path)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
|
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
|
d := "/proc/" + strconv.Itoa(pid) + "/"
|
|
f.write(d+"comm", comm+"\n")
|
|
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
|
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
|
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
|
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
|
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
|
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
|
}
|
|
|
|
func (f *fake) desktopSession() {
|
|
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
|
f.write("/run/user/1000/bus", "")
|
|
}
|
|
|
|
func (f *fake) called(prefix string) bool {
|
|
for _, c := range f.calls {
|
|
if strings.HasPrefix(c, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
|
f := newFake(t)
|
|
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
|
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
|
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
|
got := f.procs("worker")
|
|
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
|
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
|
f := newFake(t)
|
|
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
|
f.desktopSession()
|
|
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
|
s, err := f.session()
|
|
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
|
!strings.Contains(s.From, "i3") {
|
|
t.Fatalf("%+v %v", s, err)
|
|
}
|
|
}
|
|
|
|
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
|
f := newFake(t)
|
|
f.desktopSession()
|
|
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
|
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
|
f.stubborn[21] = true
|
|
ended, killed := f.stop(time.Second, "app")
|
|
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
|
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
|
}
|
|
s, _ := f.session()
|
|
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
|
"/.Xauthority -- /usr/bin/app --background"
|
|
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
|
t.Fatalf("%q", f.calls)
|
|
}
|
|
}
|
|
|
|
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
|
f := newFake(t)
|
|
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
|
t.Fatalf("%+v", a)
|
|
}
|
|
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
|
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
|
t.Fatalf("%+v", a)
|
|
}
|
|
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
|
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
|
t.Fatalf("%+v", a)
|
|
}
|
|
}
|
|
|
|
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
|
f := newFake(t)
|
|
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
|
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
|
got := f.i3Starts("app")
|
|
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
|
t.Fatalf("%q", got)
|
|
}
|
|
}
|
|
|
|
func TestACommandThatFailsIsNamed(t *testing.T) {
|
|
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
|
t.Fatal(err)
|
|
}
|
|
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
|
t.Fatal(err)
|
|
}
|
|
if err := failed(Output{}, "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
|
t.Fatalf("%+v", o)
|
|
}
|
|
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
|
t.Fatalf("%+v", o)
|
|
}
|
|
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
|
if !o.Cut || len(o.Stdout) != MostOutput {
|
|
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
|
}
|
|
}
|