@fatalmistake02/rian 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -2
  3. package/THIRD_PARTY_NOTICES.txt +673 -0
  4. package/bin/rian.cjs +12 -0
  5. package/cmd/rian/main.go +17 -0
  6. package/dist/binaries/darwin-arm64/rian +0 -0
  7. package/dist/binaries/darwin-x64/rian +0 -0
  8. package/dist/binaries/linux-arm64/rian +0 -0
  9. package/dist/binaries/linux-x64/rian +0 -0
  10. package/dist/binaries/manifest.json +29 -0
  11. package/dist/binaries/win32-arm64/rian.exe +0 -0
  12. package/dist/binaries/win32-x64/rian.exe +0 -0
  13. package/go.mod +28 -0
  14. package/go.sum +44 -0
  15. package/internal/app/app.go +298 -0
  16. package/internal/app/app_test.go +85 -0
  17. package/internal/config/config.go +189 -0
  18. package/internal/config/config_test.go +101 -0
  19. package/internal/detect/detect.go +252 -0
  20. package/internal/detect/detect_test.go +86 -0
  21. package/internal/git/git.go +166 -0
  22. package/internal/git/git_test.go +73 -0
  23. package/internal/git/window_unix.go +7 -0
  24. package/internal/git/window_windows.go +8 -0
  25. package/internal/process/platform_unix.go +19 -0
  26. package/internal/process/platform_windows.go +25 -0
  27. package/internal/process/process.go +172 -0
  28. package/internal/process/process_test.go +137 -0
  29. package/internal/project/project.go +180 -0
  30. package/internal/project/project_test.go +71 -0
  31. package/internal/tools/lookup.go +41 -0
  32. package/internal/tools/platform_unix.go +34 -0
  33. package/internal/tools/platform_windows.go +52 -0
  34. package/internal/tools/platform_windows_test.go +29 -0
  35. package/internal/tools/tools.go +152 -0
  36. package/internal/tools/tools_test.go +56 -0
  37. package/internal/ui/actions.go +259 -0
  38. package/internal/ui/forms.go +156 -0
  39. package/internal/ui/model.go +220 -0
  40. package/internal/ui/styles.go +85 -0
  41. package/internal/ui/ui_test.go +170 -0
  42. package/internal/ui/update.go +470 -0
  43. package/internal/ui/view.go +459 -0
  44. package/lib/launcher.cjs +46 -0
  45. package/lib/platforms.cjs +20 -0
  46. package/package.json +39 -4
  47. package/scripts/build-binaries.cjs +60 -0
  48. package/scripts/check-package.cjs +49 -0
  49. package/scripts/ensure-binary.cjs +11 -0
  50. package/test/npm/launcher.test.cjs +45 -0
  51. package/test/npm/package.test.cjs +26 -0
@@ -0,0 +1,166 @@
1
+ // Package git reads local repository state. Inspect never fetches the network.
2
+ package git
3
+
4
+ import (
5
+ "context"
6
+ "fmt"
7
+ "net/url"
8
+ "os"
9
+ "os/exec"
10
+ "strconv"
11
+ "strings"
12
+ "time"
13
+ )
14
+
15
+ type Status struct {
16
+ Repository bool
17
+ Branch string
18
+ Modified, Untracked, Ahead, Behind int
19
+ Remote, GitHub, Commit string
20
+ CommitTime time.Time
21
+ Error string
22
+ }
23
+
24
+ func (s Status) Dirty() bool { return s.Modified+s.Untracked > 0 }
25
+
26
+ // GitHubRepo accepts SSH, HTTPS, git protocol and ssh:// remotes, but only
27
+ // github.com. Strict owner/repo validation also makes overrides safe URLs.
28
+ func GitHubRepo(remote string) string {
29
+ remote = strings.TrimSpace(remote)
30
+ var path string
31
+ if strings.HasPrefix(remote, "git@github.com:") {
32
+ path = strings.TrimPrefix(remote, "git@github.com:")
33
+ } else {
34
+ u, err := url.Parse(remote)
35
+ if err != nil || !strings.EqualFold(u.Hostname(), "github.com") {
36
+ return ""
37
+ }
38
+ if u.Scheme != "https" && u.Scheme != "http" && u.Scheme != "ssh" && u.Scheme != "git" {
39
+ return ""
40
+ }
41
+ path = strings.TrimPrefix(u.Path, "/")
42
+ }
43
+ path = strings.TrimSuffix(strings.TrimRight(path, "/"), ".git")
44
+ parts := strings.Split(path, "/")
45
+ if len(parts) != 2 || !validPart(parts[0]) || !validPart(parts[1]) {
46
+ return ""
47
+ }
48
+ return path
49
+ }
50
+
51
+ func validPart(part string) bool {
52
+ if part == "" || part == "." || part == ".." {
53
+ return false
54
+ }
55
+ for _, r := range part {
56
+ if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
57
+ return false
58
+ }
59
+ }
60
+ return true
61
+ }
62
+
63
+ func NormalizeRepo(repo string) (string, error) {
64
+ if repo == "" {
65
+ return "", nil
66
+ }
67
+ if parsed := GitHubRepo(repo); parsed != "" {
68
+ return parsed, nil
69
+ }
70
+ if parsed := GitHubRepo("https://github.com/" + repo); parsed != "" {
71
+ return parsed, nil
72
+ }
73
+ return "", fmt.Errorf("use a GitHub owner/repository or a github.com URL")
74
+ }
75
+
76
+ func Inspect(path string) Status {
77
+ ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
78
+ defer cancel()
79
+ run := func(args ...string) (string, error) {
80
+ cmd := exec.CommandContext(ctx, "git", append([]string{"-C", path}, args...)...)
81
+ hideWindow(cmd)
82
+ output, err := cmd.Output()
83
+ if len(args) > 0 && args[0] == "status" {
84
+ return string(output), err
85
+ }
86
+ return strings.TrimSpace(string(output)), err
87
+ }
88
+ var s Status
89
+ if info, err := os.Stat(path); err != nil || !info.IsDir() {
90
+ s.Error = "This directory is no longer available"
91
+ return s
92
+ }
93
+ if _, err := exec.LookPath("git"); err != nil {
94
+ s.Error = "Git is not installed"
95
+ return s
96
+ }
97
+ root, err := run("rev-parse", "--is-inside-work-tree")
98
+ if err != nil || root != "true" {
99
+ return s
100
+ }
101
+ s.Repository = true
102
+ branch, err := run("symbolic-ref", "--quiet", "--short", "HEAD")
103
+ if err != nil {
104
+ branch, _ = run("rev-parse", "--short", "HEAD")
105
+ if branch != "" {
106
+ branch = "detached · " + branch
107
+ } else {
108
+ branch = "unborn"
109
+ }
110
+ }
111
+ s.Branch = branch
112
+ output, err := run("status", "--porcelain=v1", "-z", "--untracked-files=all")
113
+ if err != nil {
114
+ s.Error = "Unable to read Git status"
115
+ } else {
116
+ s.Modified, s.Untracked = ParsePorcelain(output)
117
+ }
118
+ s.Remote, _ = run("remote", "get-url", "origin")
119
+ if s.Remote == "" {
120
+ remotes, _ := run("remote")
121
+ if names := strings.Fields(remotes); len(names) > 0 {
122
+ s.Remote, _ = run("remote", "get-url", names[0])
123
+ }
124
+ }
125
+ s.GitHub = GitHubRepo(s.Remote)
126
+ counts, err := run("rev-list", "--left-right", "--count", "HEAD...@{upstream}")
127
+ if err == nil {
128
+ parts := strings.Fields(counts)
129
+ if len(parts) == 2 {
130
+ s.Ahead, _ = strconv.Atoi(parts[0])
131
+ s.Behind, _ = strconv.Atoi(parts[1])
132
+ }
133
+ }
134
+ commit, err := run("log", "-1", "--format=%ct%n%s")
135
+ if err == nil {
136
+ parts := strings.SplitN(commit, "\n", 2)
137
+ if len(parts) == 2 {
138
+ seconds, _ := strconv.ParseInt(parts[0], 10, 64)
139
+ s.CommitTime = time.Unix(seconds, 0)
140
+ s.Commit = parts[1]
141
+ }
142
+ }
143
+ if ctx.Err() != nil {
144
+ s.Error = "Git status timed out"
145
+ }
146
+ return s
147
+ }
148
+
149
+ func ParsePorcelain(output string) (modified, untracked int) {
150
+ entries := strings.Split(output, "\x00")
151
+ for i := 0; i < len(entries); i++ {
152
+ entry := entries[i]
153
+ if len(entry) < 3 {
154
+ continue
155
+ }
156
+ if entry[:2] == "??" {
157
+ untracked++
158
+ } else {
159
+ modified++
160
+ if entry[0] == 'R' || entry[0] == 'C' || entry[1] == 'R' || entry[1] == 'C' {
161
+ i++
162
+ }
163
+ }
164
+ }
165
+ return
166
+ }
@@ -0,0 +1,73 @@
1
+ package git
2
+
3
+ import (
4
+ "os"
5
+ "os/exec"
6
+ "path/filepath"
7
+ "testing"
8
+ )
9
+
10
+ func TestGitHubRepo(t *testing.T) {
11
+ for _, remote := range []string{"git@github.com:FatalMistake02/Cardly.git", "https://github.com/FatalMistake02/Cardly.git", "ssh://git@github.com/FatalMistake02/Cardly.git", "git://github.com/FatalMistake02/Cardly", "https://github.com/FatalMistake02/Cardly/"} {
12
+ if actual := GitHubRepo(remote); actual != "FatalMistake02/Cardly" {
13
+ t.Errorf("%q → %q", remote, actual)
14
+ }
15
+ }
16
+ for _, remote := range []string{"", "git@gitlab.com:owner/repo.git", "https://github.com.evil.test/a/b", "https://github.com/a/b/tree/main", "https://evil.test/github.com/a/b", "https://github.com/a/../b", "file:///a/b", "https://github.com/a/b%20x", "https://github.com/a/.."} {
17
+ if actual := GitHubRepo(remote); actual != "" {
18
+ t.Errorf("accepted %q as %q", remote, actual)
19
+ }
20
+ }
21
+ if actual, err := NormalizeRepo("owner/repo"); err != nil || actual != "owner/repo" {
22
+ t.Fatalf("override: %q %v", actual, err)
23
+ }
24
+ }
25
+
26
+ func TestPorcelainRenamesAndWhitespace(t *testing.T) {
27
+ modified, untracked := ParsePorcelain(" M file with spaces\x00R new name\x00old name\x00?? new file\x00 D deleted\x00")
28
+ if modified != 3 || untracked != 1 {
29
+ t.Fatalf("got modified %d, untracked %d", modified, untracked)
30
+ }
31
+ }
32
+
33
+ func TestInspectRepository(t *testing.T) {
34
+ if _, err := exec.LookPath("git"); err != nil {
35
+ t.Skip("git unavailable")
36
+ }
37
+ dir := t.TempDir()
38
+ run := func(args ...string) {
39
+ t.Helper()
40
+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
41
+ if data, err := cmd.CombinedOutput(); err != nil {
42
+ t.Fatalf("git %v: %s %v", args, data, err)
43
+ }
44
+ }
45
+ run("init", "-b", "main")
46
+ run("config", "user.name", "Rian Test")
47
+ run("config", "user.email", "rian@example.test")
48
+ run("config", "commit.gpgsign", "false")
49
+ if status := Inspect(dir); !status.Repository || status.Branch != "main" {
50
+ t.Fatalf("unborn branch: %+v", status)
51
+ }
52
+ file := filepath.Join(dir, "file.txt")
53
+ if err := os.WriteFile(file, []byte("first"), 0600); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ run("add", "--", "file.txt")
57
+ run("commit", "-m", "Initial project")
58
+ run("remote", "add", "origin", "git@github.com:owner/repo.git")
59
+ if err := os.WriteFile(file, []byte("changed"), 0600); err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0600); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ s := Inspect(dir)
66
+ if !s.Repository || s.Branch != "main" || s.Modified != 1 || s.Untracked != 1 || s.GitHub != "owner/repo" || s.Commit != "Initial project" || s.CommitTime.IsZero() || !s.Dirty() {
67
+ t.Fatalf("unexpected status: %+v", s)
68
+ }
69
+ run("checkout", "--detach")
70
+ if s = Inspect(dir); s.Branch == "main" || s.Branch == "" {
71
+ t.Fatalf("detached status: %+v", s)
72
+ }
73
+ }
@@ -0,0 +1,7 @@
1
+ //go:build !windows
2
+
3
+ package git
4
+
5
+ import "os/exec"
6
+
7
+ func hideWindow(cmd *exec.Cmd) {}
@@ -0,0 +1,8 @@
1
+ package git
2
+
3
+ import (
4
+ "os/exec"
5
+ "syscall"
6
+ )
7
+
8
+ func hideWindow(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} }
@@ -0,0 +1,19 @@
1
+ //go:build !windows
2
+
3
+ package process
4
+
5
+ import (
6
+ "errors"
7
+ "os/exec"
8
+ "syscall"
9
+ )
10
+
11
+ func prepare(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} }
12
+
13
+ func stopTree(cmd *exec.Cmd) error {
14
+ err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
15
+ if errors.Is(err, syscall.ESRCH) {
16
+ return nil
17
+ }
18
+ return err
19
+ }
@@ -0,0 +1,25 @@
1
+ package process
2
+
3
+ import (
4
+ "fmt"
5
+ "os/exec"
6
+ "strconv"
7
+ "syscall"
8
+ )
9
+
10
+ func prepare(cmd *exec.Cmd) {
11
+ if cmd.SysProcAttr == nil {
12
+ cmd.SysProcAttr = &syscall.SysProcAttr{}
13
+ }
14
+ cmd.SysProcAttr.HideWindow = true
15
+ cmd.SysProcAttr.CreationFlags |= 0x00000200
16
+ }
17
+
18
+ func stopTree(cmd *exec.Cmd) error {
19
+ kill := exec.Command("taskkill.exe", "/PID", strconv.Itoa(cmd.Process.Pid), "/T", "/F")
20
+ kill.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
21
+ if output, err := kill.CombinedOutput(); err != nil {
22
+ return fmt.Errorf("could not stop process tree: %s (%w)", output, err)
23
+ }
24
+ return nil
25
+ }
@@ -0,0 +1,172 @@
1
+ // Package process owns project processes and bounded, concurrent output buffers.
2
+ package process
3
+
4
+ import (
5
+ "fmt"
6
+ "os"
7
+ "os/exec"
8
+ "sync"
9
+ "time"
10
+
11
+ "rian/internal/tools"
12
+ )
13
+
14
+ const outputLimit = 96 * 1024
15
+
16
+ type Snapshot struct {
17
+ Command string
18
+ PID int
19
+ Started, Ended time.Time
20
+ Running bool
21
+ Stopped bool
22
+ Output string
23
+ Error string
24
+ }
25
+
26
+ type session struct {
27
+ mu sync.Mutex
28
+ cmd *exec.Cmd
29
+ state Snapshot
30
+ buffer []byte
31
+ done chan struct{}
32
+ }
33
+
34
+ func (s *session) Write(data []byte) (int, error) {
35
+ s.mu.Lock()
36
+ defer s.mu.Unlock()
37
+ n := len(data)
38
+ if n >= outputLimit {
39
+ s.buffer = append(s.buffer[:0], data[n-outputLimit:]...)
40
+ } else {
41
+ if len(s.buffer)+n > outputLimit {
42
+ s.buffer = s.buffer[len(s.buffer)+n-outputLimit:]
43
+ }
44
+ s.buffer = append(s.buffer, data...)
45
+ }
46
+ return n, nil
47
+ }
48
+
49
+ func (s *session) snapshot() Snapshot {
50
+ s.mu.Lock()
51
+ defer s.mu.Unlock()
52
+ state := s.state
53
+ state.Output = string(s.buffer)
54
+ return state
55
+ }
56
+
57
+ type Manager struct {
58
+ mu sync.Mutex
59
+ startMu sync.Mutex
60
+ sessions map[string]*session
61
+ closed bool
62
+ }
63
+
64
+ func New() *Manager { return &Manager{sessions: map[string]*session{}} }
65
+
66
+ func (m *Manager) Start(id, path, command string) (Snapshot, error) {
67
+ if info, err := os.Stat(path); err != nil || !info.IsDir() {
68
+ return Snapshot{}, fmt.Errorf("this directory is no longer available: %s", path)
69
+ }
70
+ return m.StartCommand(id, command, tools.Shell(path, command))
71
+ }
72
+
73
+ func (m *Manager) StartCommand(id, label string, cmd *exec.Cmd) (Snapshot, error) {
74
+ // Launch/shutdown serialize separately from the map lock. Screen snapshots
75
+ // never wait for an OS process launch (which can be slow on Windows).
76
+ m.startMu.Lock()
77
+ defer m.startMu.Unlock()
78
+ m.mu.Lock()
79
+ closed, existing := m.closed, m.sessions[id]
80
+ m.mu.Unlock()
81
+ if closed {
82
+ return Snapshot{}, fmt.Errorf("process manager is shutting down")
83
+ }
84
+ if existing != nil && existing.snapshot().Running {
85
+ return existing.snapshot(), fmt.Errorf("a command is already running for this project; stop it first")
86
+ }
87
+ s := &session{cmd: cmd, state: Snapshot{Command: label, Started: time.Now()}, done: make(chan struct{})}
88
+ cmd.Stdout, cmd.Stderr = s, s
89
+ prepare(cmd)
90
+ if err := cmd.Start(); err != nil {
91
+ return Snapshot{}, fmt.Errorf("could not start command: %w", err)
92
+ }
93
+ s.state.PID, s.state.Running = cmd.Process.Pid, true
94
+ m.mu.Lock()
95
+ m.sessions[id] = s
96
+ m.mu.Unlock()
97
+ go func() {
98
+ err := cmd.Wait()
99
+ s.mu.Lock()
100
+ s.state.Running, s.state.Ended = false, time.Now()
101
+ if err != nil && !s.state.Stopped {
102
+ s.state.Error = err.Error()
103
+ }
104
+ s.mu.Unlock()
105
+ close(s.done)
106
+ }()
107
+ return s.snapshot(), nil
108
+ }
109
+
110
+ func (m *Manager) Snapshot(id string) Snapshot {
111
+ m.mu.Lock()
112
+ s := m.sessions[id]
113
+ m.mu.Unlock()
114
+ if s == nil {
115
+ return Snapshot{}
116
+ }
117
+ return s.snapshot()
118
+ }
119
+
120
+ func (m *Manager) Stop(id string) error {
121
+ m.mu.Lock()
122
+ s := m.sessions[id]
123
+ m.mu.Unlock()
124
+ if s == nil || !s.snapshot().Running {
125
+ return nil
126
+ }
127
+ s.mu.Lock()
128
+ s.state.Stopped = true
129
+ s.mu.Unlock()
130
+ if err := stopTree(s.cmd); err != nil {
131
+ s.mu.Lock()
132
+ s.state.Stopped = false
133
+ s.mu.Unlock()
134
+ return err
135
+ }
136
+ select {
137
+ case <-s.done:
138
+ return nil
139
+ case <-time.After(4 * time.Second):
140
+ return fmt.Errorf("process did not exit after stop")
141
+ }
142
+ }
143
+
144
+ func (m *Manager) Active() int {
145
+ m.mu.Lock()
146
+ defer m.mu.Unlock()
147
+ n := 0
148
+ for _, s := range m.sessions {
149
+ if s.snapshot().Running {
150
+ n++
151
+ }
152
+ }
153
+ return n
154
+ }
155
+
156
+ func (m *Manager) Close() {
157
+ m.startMu.Lock()
158
+ m.mu.Lock()
159
+ m.closed = true
160
+ ids := make([]string, 0, len(m.sessions))
161
+ for id := range m.sessions {
162
+ ids = append(ids, id)
163
+ }
164
+ m.mu.Unlock()
165
+ m.startMu.Unlock()
166
+ var wg sync.WaitGroup
167
+ for _, id := range ids {
168
+ wg.Add(1)
169
+ go func(id string) { defer wg.Done(); _ = m.Stop(id) }(id)
170
+ }
171
+ wg.Wait()
172
+ }
@@ -0,0 +1,137 @@
1
+ package process
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "os/exec"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+ )
12
+
13
+ func helperCommand(t *testing.T, mode, dir string) *exec.Cmd {
14
+ t.Helper()
15
+ exe, err := os.Executable()
16
+ if err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ cmd := exec.Command(exe, "-test.run=^TestProcessHelper$")
20
+ cmd.Env = append(os.Environ(), "RIAN_TEST_HELPER="+mode, "RIAN_TEST_HELPER_DIR="+dir)
21
+ cmd.Dir = dir
22
+ return cmd
23
+ }
24
+
25
+ func TestProcessHelper(t *testing.T) {
26
+ mode := os.Getenv("RIAN_TEST_HELPER")
27
+ if mode == "" {
28
+ return
29
+ }
30
+ switch mode {
31
+ case "output":
32
+ fmt.Fprintln(os.Stdout, "hello from stdout")
33
+ fmt.Fprintln(os.Stderr, "hello from stderr")
34
+ case "long":
35
+ fmt.Fprintln(os.Stdout, "ready")
36
+ time.Sleep(time.Minute)
37
+ case "tree":
38
+ exe, _ := os.Executable()
39
+ child := exec.Command(exe, "-test.run=^TestProcessHelper$")
40
+ child.Env = append(os.Environ(), "RIAN_TEST_HELPER=child")
41
+ if err := child.Start(); err != nil {
42
+ fmt.Fprintln(os.Stdout, err)
43
+ os.Exit(2)
44
+ }
45
+ os.WriteFile(filepath.Join(os.Getenv("RIAN_TEST_HELPER_DIR"), "child.pid"), []byte(fmt.Sprint(child.Process.Pid)), 0600)
46
+ fmt.Fprintln(os.Stdout, "ready")
47
+ time.Sleep(time.Minute)
48
+ case "child":
49
+ file, err := os.OpenFile(filepath.Join(os.Getenv("RIAN_TEST_HELPER_DIR"), "child.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
50
+ if err != nil {
51
+ os.Exit(3)
52
+ }
53
+ defer file.Close()
54
+ for range 1000 {
55
+ file.WriteString(".")
56
+ file.Sync()
57
+ time.Sleep(20 * time.Millisecond)
58
+ }
59
+ }
60
+ os.Exit(0)
61
+ }
62
+
63
+ func until(t *testing.T, condition func() bool) {
64
+ t.Helper()
65
+ deadline := time.Now().Add(5 * time.Second)
66
+ for time.Now().Before(deadline) {
67
+ if condition() {
68
+ return
69
+ }
70
+ time.Sleep(20 * time.Millisecond)
71
+ }
72
+ t.Fatal("process state did not arrive")
73
+ }
74
+
75
+ func TestCaptureAndOneProcessPerProject(t *testing.T) {
76
+ manager := New()
77
+ defer manager.Close()
78
+ dir := t.TempDir()
79
+ if _, err := manager.StartCommand("p", "helper output", helperCommand(t, "output", dir)); err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ until(t, func() bool { return !manager.Snapshot("p").Running })
83
+ s := manager.Snapshot("p")
84
+ if !strings.Contains(s.Output, "stdout") || !strings.Contains(s.Output, "stderr") || s.PID == 0 || s.Started.IsZero() || s.Ended.IsZero() {
85
+ t.Fatalf("missing process information: %+v", s)
86
+ }
87
+ if _, err := manager.StartCommand("p", "long", helperCommand(t, "long", dir)); err != nil {
88
+ t.Fatal(err)
89
+ }
90
+ if _, err := manager.StartCommand("p", "duplicate", helperCommand(t, "long", dir)); err == nil {
91
+ t.Fatal("a second process for one project was accepted")
92
+ }
93
+ if err := manager.Stop("p"); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ if s = manager.Snapshot("p"); s.Running || !s.Stopped || s.Error != "" {
97
+ t.Fatalf("stop failed: %+v", s)
98
+ }
99
+ }
100
+
101
+ func TestStopIncludesChildProcess(t *testing.T) {
102
+ manager := New()
103
+ defer manager.Close()
104
+ dir := t.TempDir()
105
+ if _, err := manager.StartCommand("tree", "tree", helperCommand(t, "tree", dir)); err != nil {
106
+ t.Fatal(err)
107
+ }
108
+ file := filepath.Join(dir, "child.log")
109
+ until(t, func() bool { info, err := os.Stat(file); return err == nil && info.Size() > 0 })
110
+ if err := manager.Stop("tree"); err != nil {
111
+ t.Fatal(err)
112
+ }
113
+ before, _ := os.Stat(file)
114
+ time.Sleep(200 * time.Millisecond)
115
+ after, _ := os.Stat(file)
116
+ if before.Size() != after.Size() {
117
+ t.Fatal("a descendant process kept running after stop")
118
+ }
119
+ }
120
+
121
+ func TestOutputIsBounded(t *testing.T) {
122
+ s := &session{}
123
+ s.Write([]byte(strings.Repeat("a", outputLimit*2)))
124
+ s.Write([]byte("tail"))
125
+ state := s.snapshot()
126
+ if len(state.Output) != outputLimit || !strings.HasSuffix(state.Output, "tail") {
127
+ t.Fatal("recent output was not bounded correctly")
128
+ }
129
+ }
130
+
131
+ func TestShutdownRejectsLateStarts(t *testing.T) {
132
+ manager := New()
133
+ manager.Close()
134
+ if _, err := manager.StartCommand("late", "late", helperCommand(t, "long", t.TempDir())); err == nil {
135
+ t.Fatal("a process started after shutdown")
136
+ }
137
+ }