fix(ax): align imports, tests, and usage docs

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-26 10:48:55 +00:00
parent 1b4efe0f67
commit e9bb6a8968
24 changed files with 269 additions and 235 deletions

View file

@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestRingBuffer(t *testing.T) {
func TestRingBuffer_Good(t *testing.T) {
t.Run("write and read", func(t *testing.T) {
rb := NewRingBuffer(10)

View file

@ -2,8 +2,6 @@ package process
import (
"context"
"errors"
"os"
"sync"
"time"
@ -96,7 +94,7 @@ func (d *Daemon) Start() error {
// Auto-register if registry is set
if d.opts.Registry != nil {
entry := d.opts.RegistryEntry
entry.PID = os.Getpid()
entry.PID = currentPID()
if d.health != nil {
entry.Health = d.health.Addr()
}
@ -144,7 +142,7 @@ func (d *Daemon) Stop() error {
}
if d.pid != nil {
if err := d.pid.Release(); err != nil && !os.IsNotExist(err) {
if err := d.pid.Release(); err != nil && !isNotExist(err) {
errs = append(errs, coreerr.E("Daemon.Stop", "pid file", err))
}
}
@ -157,7 +155,7 @@ func (d *Daemon) Stop() error {
d.running = false
if len(errs) > 0 {
return errors.Join(errs...)
return coreerr.Join(errs...)
}
return nil
}

View file

@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestDaemon_StartAndStop(t *testing.T) {
func TestDaemon_Lifecycle_Good(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "test.pid")
d := NewDaemon(DaemonOptions{
@ -36,7 +36,7 @@ func TestDaemon_StartAndStop(t *testing.T) {
require.NoError(t, err)
}
func TestDaemon_DoubleStartFails(t *testing.T) {
func TestDaemon_Start_Bad_AlreadyRunning(t *testing.T) {
d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0",
})
@ -50,7 +50,7 @@ func TestDaemon_DoubleStartFails(t *testing.T) {
assert.Contains(t, err.Error(), "already running")
}
func TestDaemon_RunWithoutStartFails(t *testing.T) {
func TestDaemon_Run_Bad_NotStarted(t *testing.T) {
d := NewDaemon(DaemonOptions{})
ctx, cancel := context.WithCancel(context.Background())
@ -61,7 +61,7 @@ func TestDaemon_RunWithoutStartFails(t *testing.T) {
assert.Contains(t, err.Error(), "not started")
}
func TestDaemon_SetReady(t *testing.T) {
func TestDaemon_SetReady_Good(t *testing.T) {
d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0",
})
@ -83,17 +83,17 @@ func TestDaemon_SetReady(t *testing.T) {
_ = resp.Body.Close()
}
func TestDaemon_NoHealthAddrReturnsEmpty(t *testing.T) {
func TestDaemon_HealthAddr_Good_EmptyWhenDisabled(t *testing.T) {
d := NewDaemon(DaemonOptions{})
assert.Empty(t, d.HealthAddr())
}
func TestDaemon_DefaultShutdownTimeout(t *testing.T) {
func TestDaemon_ShutdownTimeout_Good_Default(t *testing.T) {
d := NewDaemon(DaemonOptions{})
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
}
func TestDaemon_RunBlocksUntilCancelled(t *testing.T) {
func TestDaemon_Run_Good_BlocksUntilCancelled(t *testing.T) {
d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0",
})
@ -126,7 +126,7 @@ func TestDaemon_RunBlocksUntilCancelled(t *testing.T) {
}
}
func TestDaemon_StopIdempotent(t *testing.T) {
func TestDaemon_Stop_Good_Idempotent(t *testing.T) {
d := NewDaemon(DaemonOptions{})
// Stop without Start should be a no-op
@ -134,7 +134,7 @@ func TestDaemon_StopIdempotent(t *testing.T) {
assert.NoError(t, err)
}
func TestDaemon_AutoRegisters(t *testing.T) {
func TestDaemon_Registry_Good_AutoRegisters(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry(filepath.Join(dir, "daemons"))

3
exec/doc.go Normal file
View file

@ -0,0 +1,3 @@
// Package exec provides a small command wrapper around `os/exec` with
// structured logging hooks.
package exec

View file

@ -12,7 +12,7 @@ import (
coreerr "dappco.re/go/core/log"
)
// Options configuration for command execution
// Options configures command execution.
type Options struct {
Dir string
Env []string
@ -23,7 +23,7 @@ type Options struct {
// Background bool
}
// Command wraps os/exec.Command with logging and context
// Command wraps `os/exec.Command` with logging and context.
func Command(ctx context.Context, name string, args ...string) *Cmd {
return &Cmd{
name: name,
@ -32,7 +32,7 @@ func Command(ctx context.Context, name string, args ...string) *Cmd {
}
}
// Cmd represents a wrapped command
// Cmd represents a wrapped command.
type Cmd struct {
name string
args []string
@ -42,31 +42,31 @@ type Cmd struct {
logger Logger
}
// WithDir sets the working directory
// WithDir sets the working directory.
func (c *Cmd) WithDir(dir string) *Cmd {
c.opts.Dir = dir
return c
}
// WithEnv sets the environment variables
// WithEnv sets the environment variables.
func (c *Cmd) WithEnv(env []string) *Cmd {
c.opts.Env = env
return c
}
// WithStdin sets stdin
// WithStdin sets stdin.
func (c *Cmd) WithStdin(r io.Reader) *Cmd {
c.opts.Stdin = r
return c
}
// WithStdout sets stdout
// WithStdout sets stdout.
func (c *Cmd) WithStdout(w io.Writer) *Cmd {
c.opts.Stdout = w
return c
}
// WithStderr sets stderr
// WithStderr sets stderr.
func (c *Cmd) WithStderr(w io.Writer) *Cmd {
c.opts.Stderr = w
return c

View file

@ -107,14 +107,14 @@ func TestCommand_CombinedOutput_Good(t *testing.T) {
}
}
func TestNopLogger(t *testing.T) {
func TestNopLogger_Good(t *testing.T) {
// Verify NopLogger doesn't panic
var nop exec.NopLogger
nop.Debug("msg", "key", "val")
nop.Error("msg", "key", "val")
}
func TestSetDefaultLogger(t *testing.T) {
func TestSetDefaultLogger_Good(t *testing.T) {
original := exec.DefaultLogger()
defer exec.SetDefaultLogger(original)
@ -132,7 +132,7 @@ func TestSetDefaultLogger(t *testing.T) {
}
}
func TestCommand_UsesDefaultLogger(t *testing.T) {
func TestCommand_UsesDefaultLogger_Good(t *testing.T) {
original := exec.DefaultLogger()
defer exec.SetDefaultLogger(original)
@ -147,7 +147,7 @@ func TestCommand_UsesDefaultLogger(t *testing.T) {
}
}
func TestCommand_WithDir(t *testing.T) {
func TestCommand_WithDir_Good(t *testing.T) {
ctx := context.Background()
out, err := exec.Command(ctx, "pwd").
WithDir("/tmp").
@ -162,7 +162,7 @@ func TestCommand_WithDir(t *testing.T) {
}
}
func TestCommand_WithEnv(t *testing.T) {
func TestCommand_WithEnv_Good(t *testing.T) {
ctx := context.Background()
out, err := exec.Command(ctx, "sh", "-c", "echo $TEST_EXEC_VAR").
WithEnv([]string{"TEST_EXEC_VAR=exec_val"}).
@ -176,7 +176,7 @@ func TestCommand_WithEnv(t *testing.T) {
}
}
func TestCommand_WithStdinStdoutStderr(t *testing.T) {
func TestCommand_WithStdinStdoutStderr_Good(t *testing.T) {
ctx := context.Background()
input := strings.NewReader("piped input\n")
var stdout, stderr strings.Builder

View file

@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestGlobal_DefaultNotInitialized(t *testing.T) {
func TestGlobal_Default_Bad_NotInitialized(t *testing.T) {
old := defaultService.Swap(nil)
defer func() {
if old != nil {
@ -51,7 +51,7 @@ func newGlobalTestService(t *testing.T) *Service {
return raw.(*Service)
}
func TestGlobal_SetDefault(t *testing.T) {
func TestGlobal_SetDefault_Good(t *testing.T) {
t.Run("sets and retrieves service", func(t *testing.T) {
old := defaultService.Swap(nil)
defer func() {
@ -72,7 +72,7 @@ func TestGlobal_SetDefault(t *testing.T) {
})
}
func TestGlobal_ConcurrentDefault(t *testing.T) {
func TestGlobal_Default_Good_Concurrent(t *testing.T) {
old := defaultService.Swap(nil)
defer func() {
if old != nil {
@ -97,7 +97,7 @@ func TestGlobal_ConcurrentDefault(t *testing.T) {
wg.Wait()
}
func TestGlobal_ConcurrentSetDefault(t *testing.T) {
func TestGlobal_SetDefault_Good_Concurrent(t *testing.T) {
old := defaultService.Swap(nil)
defer func() {
if old != nil {
@ -134,7 +134,7 @@ func TestGlobal_ConcurrentSetDefault(t *testing.T) {
assert.True(t, found, "Default should be one of the set services")
}
func TestGlobal_ConcurrentOperations(t *testing.T) {
func TestGlobal_Operations_Good_Concurrent(t *testing.T) {
old := defaultService.Swap(nil)
defer func() {
if old != nil {
@ -195,7 +195,7 @@ func TestGlobal_ConcurrentOperations(t *testing.T) {
wg2.Wait()
}
func TestGlobal_StartWithOptions(t *testing.T) {
func TestGlobal_StartWithOptions_Good(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
@ -216,7 +216,7 @@ func TestGlobal_StartWithOptions(t *testing.T) {
assert.Contains(t, proc.Output(), "with options")
}
func TestGlobal_RunWithOptions(t *testing.T) {
func TestGlobal_RunWithOptions_Good(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {
@ -233,7 +233,7 @@ func TestGlobal_RunWithOptions(t *testing.T) {
assert.Contains(t, r.Value.(string), "run options")
}
func TestGlobal_Running(t *testing.T) {
func TestGlobal_Running_Good(t *testing.T) {
svc, _ := newTestService(t)
old := defaultService.Swap(svc)
defer func() {

5
go.mod
View file

@ -13,11 +13,6 @@ require (
)
require (
dappco.re/go/core/api v0.2.0
dappco.re/go/core/i18n v0.2.0
dappco.re/go/core/process v0.3.0
dappco.re/go/core/scm v0.4.0
dappco.re/go/core/store v0.2.0
forge.lthn.ai/core/go-io v0.1.5 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/99designs/gqlgen v0.17.88 // indirect

8
go.sum
View file

@ -1,3 +1,11 @@
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
dappco.re/go/core/ws v0.3.0 h1:ZxR8y5pfrWvnCHVN7qExXz7fdP5a063uNqyqE0Ab8pQ=
dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic=
forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o=
forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII=
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=

View file

@ -2,12 +2,12 @@ package process
import (
"context"
"fmt"
"net"
"net/http"
"sync"
"time"
"dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
@ -58,13 +58,13 @@ func (h *HealthServer) Start() error {
for _, check := range checks {
if err := check(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
_, _ = w.Write([]byte("unhealthy: " + err.Error() + "\n"))
return
}
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ok")
_, _ = w.Write([]byte("ok\n"))
})
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
@ -74,17 +74,17 @@ func (h *HealthServer) Start() error {
if !ready {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintln(w, "not ready")
_, _ = w.Write([]byte("not ready\n"))
return
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ready")
_, _ = w.Write([]byte("ready\n"))
})
listener, err := net.Listen("tcp", h.addr)
if err != nil {
return coreerr.E("HealthServer.Start", fmt.Sprintf("failed to listen on %s", h.addr), err)
return coreerr.E("HealthServer.Start", "failed to listen on "+h.addr, err)
}
h.listener = listener
@ -117,7 +117,7 @@ func (h *HealthServer) Addr() string {
// (in milliseconds) expires. Returns true if healthy, false on timeout.
func WaitForHealth(addr string, timeoutMs int) bool {
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
url := fmt.Sprintf("http://%s/health", addr)
url := core.Concat("http://", addr, "/health")
client := &http.Client{Timeout: 2 * time.Second}

View file

@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestHealthServer_Endpoints(t *testing.T) {
func TestHealthServer_Endpoints_Good(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0")
err := hs.Start()
require.NoError(t, err)
@ -36,7 +36,7 @@ func TestHealthServer_Endpoints(t *testing.T) {
_ = resp.Body.Close()
}
func TestHealthServer_WithChecks(t *testing.T) {
func TestHealthServer_WithChecks_Good(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0")
healthy := true
@ -66,7 +66,7 @@ func TestHealthServer_WithChecks(t *testing.T) {
_ = resp.Body.Close()
}
func TestWaitForHealth_Reachable(t *testing.T) {
func TestWaitForHealth_Good_Reachable(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0")
require.NoError(t, hs.Start())
defer func() { _ = hs.Stop(context.Background()) }()
@ -75,7 +75,7 @@ func TestWaitForHealth_Reachable(t *testing.T) {
assert.True(t, ok)
}
func TestWaitForHealth_Unreachable(t *testing.T) {
func TestWaitForHealth_Bad_Unreachable(t *testing.T) {
ok := WaitForHealth("127.0.0.1:19999", 500)
assert.False(t, ok)
}

17
internal/jsonx/jsonx.go Normal file
View file

@ -0,0 +1,17 @@
package jsonx
import "encoding/json"
// MarshalIndent marshals v as indented JSON and returns the string form.
func MarshalIndent(v any) (string, error) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
return string(data), nil
}
// Unmarshal unmarshals a JSON string into v.
func Unmarshal(data string, v any) error {
return json.Unmarshal([]byte(data), v)
}

View file

@ -1,11 +1,9 @@
package process
import (
"fmt"
"os"
"path/filepath"
"bytes"
"path"
"strconv"
"strings"
"sync"
"syscall"
@ -31,24 +29,24 @@ func (p *PIDFile) Acquire() error {
defer p.mu.Unlock()
if data, err := coreio.Local.Read(p.path); err == nil {
pid, err := strconv.Atoi(strings.TrimSpace(data))
pid, err := strconv.Atoi(string(bytes.TrimSpace([]byte(data))))
if err == nil && pid > 0 {
if proc, err := os.FindProcess(pid); err == nil {
if proc, err := processHandle(pid); err == nil {
if err := proc.Signal(syscall.Signal(0)); err == nil {
return coreerr.E("PIDFile.Acquire", fmt.Sprintf("another instance is running (PID %d)", pid), nil)
return coreerr.E("PIDFile.Acquire", "another instance is running (PID "+strconv.Itoa(pid)+")", nil)
}
}
}
_ = coreio.Local.Delete(p.path)
}
if dir := filepath.Dir(p.path); dir != "." {
if dir := path.Dir(p.path); dir != "." {
if err := coreio.Local.EnsureDir(dir); err != nil {
return coreerr.E("PIDFile.Acquire", "failed to create PID directory", err)
}
}
pid := os.Getpid()
pid := currentPID()
if err := coreio.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
return coreerr.E("PIDFile.Acquire", "failed to write PID file", err)
}
@ -80,12 +78,12 @@ func ReadPID(path string) (int, bool) {
return 0, false
}
pid, err := strconv.Atoi(strings.TrimSpace(data))
pid, err := strconv.Atoi(string(bytes.TrimSpace([]byte(data))))
if err != nil || pid <= 0 {
return 0, false
}
proc, err := os.FindProcess(pid)
proc, err := processHandle(pid)
if err != nil {
return pid, false
}

View file

@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestPIDFile_AcquireAndRelease(t *testing.T) {
func TestPIDFile_Acquire_Good(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "test.pid")
pid := NewPIDFile(pidPath)
err := pid.Acquire()
@ -23,7 +23,7 @@ func TestPIDFile_AcquireAndRelease(t *testing.T) {
assert.True(t, os.IsNotExist(err))
}
func TestPIDFile_StalePID(t *testing.T) {
func TestPIDFile_Acquire_Good_StalePID(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "stale.pid")
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
pid := NewPIDFile(pidPath)
@ -33,7 +33,7 @@ func TestPIDFile_StalePID(t *testing.T) {
require.NoError(t, err)
}
func TestPIDFile_CreatesParentDirectory(t *testing.T) {
func TestPIDFile_Acquire_Good_CreatesParentDirectory(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid")
pid := NewPIDFile(pidPath)
err := pid.Acquire()
@ -42,18 +42,18 @@ func TestPIDFile_CreatesParentDirectory(t *testing.T) {
require.NoError(t, err)
}
func TestPIDFile_Path(t *testing.T) {
func TestPIDFile_Path_Good(t *testing.T) {
pid := NewPIDFile("/tmp/test.pid")
assert.Equal(t, "/tmp/test.pid", pid.Path())
}
func TestReadPID_Missing(t *testing.T) {
func TestReadPID_Bad_Missing(t *testing.T) {
pid, running := ReadPID("/nonexistent/path.pid")
assert.Equal(t, 0, pid)
assert.False(t, running)
}
func TestReadPID_InvalidContent(t *testing.T) {
func TestReadPID_Bad_InvalidContent(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.pid")
require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644))
pid, running := ReadPID(path)
@ -61,7 +61,7 @@ func TestReadPID_InvalidContent(t *testing.T) {
assert.False(t, running)
}
func TestReadPID_StalePID(t *testing.T) {
func TestReadPID_Bad_StalePID(t *testing.T) {
path := filepath.Join(t.TempDir(), "stale.pid")
require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644))
pid, running := ReadPID(path)

View file

@ -2,10 +2,7 @@ package process
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"sync"
"syscall"
"time"
@ -13,6 +10,11 @@ import (
coreerr "dappco.re/go/core/log"
)
type processStdin interface {
Write(p []byte) (n int, err error)
Close() error
}
// Process represents a managed external process.
type Process struct {
ID string
@ -25,11 +27,11 @@ type Process struct {
ExitCode int
Duration time.Duration
cmd *exec.Cmd
cmd *execCmd
ctx context.Context
cancel context.CancelFunc
output *RingBuffer
stdin io.WriteCloser
stdin processStdin
done chan struct{}
mu sync.RWMutex
gracePeriod time.Duration
@ -92,13 +94,13 @@ func (p *Process) Wait() error {
p.mu.RLock()
defer p.mu.RUnlock()
if p.Status == StatusFailed {
return coreerr.E("Process.Wait", fmt.Sprintf("process failed to start: %s", p.ID), nil)
return coreerr.E("Process.Wait", "process failed to start: "+p.ID, nil)
}
if p.Status == StatusKilled {
return coreerr.E("Process.Wait", fmt.Sprintf("process was killed: %s", p.ID), nil)
return coreerr.E("Process.Wait", "process was killed: "+p.ID, nil)
}
if p.ExitCode != 0 {
return coreerr.E("Process.Wait", fmt.Sprintf("process exited with code %d", p.ExitCode), nil)
return coreerr.E("Process.Wait", "process exited with code "+strconv.Itoa(p.ExitCode), nil)
}
return nil
}
@ -175,22 +177,6 @@ func (p *Process) terminate() error {
return syscall.Kill(pid, syscall.SIGTERM)
}
// Signal sends a signal to the process.
func (p *Process) Signal(sig os.Signal) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.Status != StatusRunning {
return ErrProcessNotRunning
}
if p.cmd == nil || p.cmd.Process == nil {
return nil
}
return p.cmd.Process.Signal(sig)
}
// SendInput writes to the process stdin.
func (p *Process) SendInput(input string) error {
p.mu.RLock()

View file

@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestProcess_Info(t *testing.T) {
func TestProcess_Info_Good(t *testing.T) {
svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "hello")
@ -26,7 +26,7 @@ func TestProcess_Info(t *testing.T) {
assert.Greater(t, info.Duration, time.Duration(0))
}
func TestProcess_Output(t *testing.T) {
func TestProcess_Output_Good(t *testing.T) {
t.Run("captures stdout", func(t *testing.T) {
svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "hello world")
@ -44,7 +44,7 @@ func TestProcess_Output(t *testing.T) {
})
}
func TestProcess_IsRunning(t *testing.T) {
func TestProcess_IsRunning_Good(t *testing.T) {
t.Run("true while running", func(t *testing.T) {
svc, _ := newTestService(t)
ctx, cancel := context.WithCancel(context.Background())
@ -66,7 +66,7 @@ func TestProcess_IsRunning(t *testing.T) {
})
}
func TestProcess_Wait(t *testing.T) {
func TestProcess_Wait_Good(t *testing.T) {
t.Run("returns nil on success", func(t *testing.T) {
svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "ok")
@ -82,7 +82,7 @@ func TestProcess_Wait(t *testing.T) {
})
}
func TestProcess_Done(t *testing.T) {
func TestProcess_Done_Good(t *testing.T) {
t.Run("channel closes on completion", func(t *testing.T) {
svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "echo", "test")
@ -95,7 +95,7 @@ func TestProcess_Done(t *testing.T) {
})
}
func TestProcess_Kill(t *testing.T) {
func TestProcess_Kill_Good(t *testing.T) {
t.Run("terminates running process", func(t *testing.T) {
svc, _ := newTestService(t)
ctx, cancel := context.WithCancel(context.Background())
@ -123,7 +123,7 @@ func TestProcess_Kill(t *testing.T) {
})
}
func TestProcess_SendInput(t *testing.T) {
func TestProcess_SendInput_Good(t *testing.T) {
t.Run("writes to stdin", func(t *testing.T) {
svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "cat")
@ -145,7 +145,7 @@ func TestProcess_SendInput(t *testing.T) {
})
}
func TestProcess_Signal(t *testing.T) {
func TestProcess_Signal_Good(t *testing.T) {
t.Run("sends signal to running process", func(t *testing.T) {
svc, _ := newTestService(t)
ctx, cancel := context.WithCancel(context.Background())
@ -171,7 +171,7 @@ func TestProcess_Signal(t *testing.T) {
})
}
func TestProcess_CloseStdin(t *testing.T) {
func TestProcess_CloseStdin_Good(t *testing.T) {
t.Run("closes stdin pipe", func(t *testing.T) {
svc, _ := newTestService(t)
proc := startProc(t, svc, context.Background(), "cat")
@ -196,7 +196,7 @@ func TestProcess_CloseStdin(t *testing.T) {
})
}
func TestProcess_Timeout(t *testing.T) {
func TestProcess_Timeout_Good(t *testing.T) {
t.Run("kills process after timeout", func(t *testing.T) {
svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{
@ -229,7 +229,7 @@ func TestProcess_Timeout(t *testing.T) {
})
}
func TestProcess_Shutdown(t *testing.T) {
func TestProcess_Shutdown_Good(t *testing.T) {
t.Run("graceful with grace period", func(t *testing.T) {
svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{
@ -271,7 +271,7 @@ func TestProcess_Shutdown(t *testing.T) {
})
}
func TestProcess_KillGroup(t *testing.T) {
func TestProcess_KillGroup_Good(t *testing.T) {
t.Run("kills child processes", func(t *testing.T) {
svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{
@ -295,7 +295,7 @@ func TestProcess_KillGroup(t *testing.T) {
})
}
func TestProcess_TimeoutWithGrace(t *testing.T) {
func TestProcess_TimeoutWithGrace_Good(t *testing.T) {
t.Run("timeout triggers graceful shutdown", func(t *testing.T) {
svc, _ := newTestService(t)
r := svc.StartWithOptions(context.Background(), RunOptions{

View file

@ -3,9 +3,7 @@ package process
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"strconv"
coreerr "dappco.re/go/core/log"
)
@ -30,9 +28,9 @@ func (p *Program) Find() error {
if p.Name == "" {
return coreerr.E("Program.Find", "program name is empty", nil)
}
path, err := exec.LookPath(p.Name)
path, err := execLookPath(p.Name)
if err != nil {
return coreerr.E("Program.Find", fmt.Sprintf("%q: not found in PATH", p.Name), ErrProgramNotFound)
return coreerr.E("Program.Find", strconv.Quote(p.Name)+": not found in PATH", ErrProgramNotFound)
}
p.Path = path
return nil
@ -54,7 +52,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin
}
var out bytes.Buffer
cmd := exec.CommandContext(ctx, binary, args...)
cmd := execCommandContext(ctx, binary, args...)
cmd.Stdout = &out
cmd.Stderr = &out
if dir != "" {
@ -62,7 +60,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin
}
if err := cmd.Run(); err != nil {
return strings.TrimSpace(out.String()), coreerr.E("Program.RunDir", fmt.Sprintf("%q: command failed", p.Name), err)
return string(bytes.TrimSpace(out.Bytes())), coreerr.E("Program.RunDir", strconv.Quote(p.Name)+": command failed", err)
}
return strings.TrimSpace(out.String()), nil
return string(bytes.TrimSpace(out.Bytes())), nil
}

View file

@ -19,25 +19,25 @@ func testCtx(t *testing.T) context.Context {
return ctx
}
func TestProgram_Find_KnownBinary(t *testing.T) {
func TestProgram_Find_Good_KnownBinary(t *testing.T) {
p := &process.Program{Name: "echo"}
require.NoError(t, p.Find())
assert.NotEmpty(t, p.Path)
}
func TestProgram_Find_UnknownBinary(t *testing.T) {
func TestProgram_Find_Bad_UnknownBinary(t *testing.T) {
p := &process.Program{Name: "no-such-binary-xyzzy-42"}
err := p.Find()
require.Error(t, err)
assert.ErrorIs(t, err, process.ErrProgramNotFound)
}
func TestProgram_Find_EmptyName(t *testing.T) {
func TestProgram_Find_Bad_EmptyName(t *testing.T) {
p := &process.Program{}
require.Error(t, p.Find())
}
func TestProgram_Run_ReturnsOutput(t *testing.T) {
func TestProgram_Run_Good_ReturnsOutput(t *testing.T) {
p := &process.Program{Name: "echo"}
require.NoError(t, p.Find())
@ -46,7 +46,7 @@ func TestProgram_Run_ReturnsOutput(t *testing.T) {
assert.Equal(t, "hello", out)
}
func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) {
func TestProgram_Run_Good_FallsBackToName(t *testing.T) {
// Path is empty; RunDir should fall back to Name for OS PATH resolution.
p := &process.Program{Name: "echo"}
@ -55,7 +55,7 @@ func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) {
assert.Equal(t, "fallback", out)
}
func TestProgram_RunDir_UsesDirectory(t *testing.T) {
func TestProgram_RunDir_Good_UsesDirectory(t *testing.T) {
p := &process.Program{Name: "pwd"}
require.NoError(t, p.Find())
@ -71,7 +71,7 @@ func TestProgram_RunDir_UsesDirectory(t *testing.T) {
assert.Equal(t, canonicalDir, canonicalOut)
}
func TestProgram_Run_FailingCommand(t *testing.T) {
func TestProgram_Run_Bad_FailingCommand(t *testing.T) {
p := &process.Program{Name: "false"}
require.NoError(t, p.Find())

View file

@ -1,15 +1,14 @@
package process
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"path"
"syscall"
"time"
"dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/process/internal/jsonx"
)
// DaemonEntry records a running daemon in the registry.
@ -35,11 +34,11 @@ func NewRegistry(dir string) *Registry {
// DefaultRegistry returns a registry using ~/.core/daemons/.
func DefaultRegistry() *Registry {
home, err := os.UserHomeDir()
home, err := userHomeDir()
if err != nil {
home = os.TempDir()
home = tempDir()
}
return NewRegistry(filepath.Join(home, ".core", "daemons"))
return NewRegistry(path.Join(home, ".core", "daemons"))
}
// Register writes a daemon entry to the registry directory.
@ -54,7 +53,7 @@ func (r *Registry) Register(entry DaemonEntry) error {
return coreerr.E("Registry.Register", "failed to create registry directory", err)
}
data, err := json.MarshalIndent(entry, "", " ")
data, err := jsonx.MarshalIndent(entry)
if err != nil {
return coreerr.E("Registry.Register", "failed to marshal entry", err)
}
@ -84,7 +83,7 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
}
var entry DaemonEntry
if err := json.Unmarshal([]byte(data), &entry); err != nil {
if err := jsonx.Unmarshal(data, &entry); err != nil {
_ = coreio.Local.Delete(path)
return nil, false
}
@ -99,20 +98,28 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
// List returns all alive daemon entries, pruning any with dead PIDs.
func (r *Registry) List() ([]DaemonEntry, error) {
matches, err := filepath.Glob(filepath.Join(r.dir, "*.json"))
if !coreio.Local.Exists(r.dir) {
return nil, nil
}
entries, err := coreio.Local.List(r.dir)
if err != nil {
return nil, err
return nil, coreerr.E("Registry.List", "failed to list registry directory", err)
}
var alive []DaemonEntry
for _, path := range matches {
for _, entryFile := range entries {
if entryFile.IsDir() || !core.HasSuffix(entryFile.Name(), ".json") {
continue
}
path := path.Join(r.dir, entryFile.Name())
data, err := coreio.Local.Read(path)
if err != nil {
continue
}
var entry DaemonEntry
if err := json.Unmarshal([]byte(data), &entry); err != nil {
if err := jsonx.Unmarshal(data, &entry); err != nil {
_ = coreio.Local.Delete(path)
continue
}
@ -130,8 +137,8 @@ func (r *Registry) List() ([]DaemonEntry, error) {
// entryPath returns the filesystem path for a daemon entry.
func (r *Registry) entryPath(code, daemon string) string {
name := strings.ReplaceAll(code, "/", "-") + "-" + strings.ReplaceAll(daemon, "/", "-") + ".json"
return filepath.Join(r.dir, name)
name := sanitizeRegistryComponent(code) + "-" + sanitizeRegistryComponent(daemon) + ".json"
return path.Join(r.dir, name)
}
// isAlive checks whether a process with the given PID is running.
@ -139,9 +146,21 @@ func isAlive(pid int) bool {
if pid <= 0 {
return false
}
proc, err := os.FindProcess(pid)
proc, err := processHandle(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
func sanitizeRegistryComponent(value string) string {
buf := make([]byte, len(value))
for i := 0; i < len(value); i++ {
if value[i] == '/' {
buf[i] = '-'
continue
}
buf[i] = value[i]
}
return string(buf)
}

View file

@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestRegistry_RegisterAndGet(t *testing.T) {
func TestRegistry_Register_Good(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry(dir)
@ -39,7 +39,7 @@ func TestRegistry_RegisterAndGet(t *testing.T) {
assert.Equal(t, started, got.Started)
}
func TestRegistry_Unregister(t *testing.T) {
func TestRegistry_Unregister_Good(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry(dir)
@ -65,7 +65,7 @@ func TestRegistry_Unregister(t *testing.T) {
assert.True(t, os.IsNotExist(err))
}
func TestRegistry_List(t *testing.T) {
func TestRegistry_List_Good(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry(dir)
@ -79,7 +79,7 @@ func TestRegistry_List(t *testing.T) {
assert.Len(t, entries, 2)
}
func TestRegistry_List_PrunesStale(t *testing.T) {
func TestRegistry_List_Good_PrunesStale(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry(dir)
@ -100,7 +100,7 @@ func TestRegistry_List_PrunesStale(t *testing.T) {
assert.True(t, os.IsNotExist(err))
}
func TestRegistry_Get_NotFound(t *testing.T) {
func TestRegistry_Get_Bad_NotFound(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry(dir)
@ -109,7 +109,7 @@ func TestRegistry_Get_NotFound(t *testing.T) {
assert.False(t, ok)
}
func TestRegistry_CreatesDirectory(t *testing.T) {
func TestRegistry_Register_Good_CreatesDirectory(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons")
reg := NewRegistry(dir)
@ -121,7 +121,7 @@ func TestRegistry_CreatesDirectory(t *testing.T) {
assert.True(t, info.IsDir())
}
func TestDefaultRegistry(t *testing.T) {
func TestDefaultRegistry_Good(t *testing.T) {
reg := DefaultRegistry()
assert.NotNil(t, reg)
}

View file

@ -20,7 +20,7 @@ func newTestRunner(t *testing.T) *Runner {
return NewRunner(raw.(*Service))
}
func TestRunner_RunSequential(t *testing.T) {
func TestRunner_RunSequential_Good(t *testing.T) {
t.Run("all pass", func(t *testing.T) {
runner := newTestRunner(t)
@ -70,7 +70,7 @@ func TestRunner_RunSequential(t *testing.T) {
})
}
func TestRunner_RunParallel(t *testing.T) {
func TestRunner_RunParallel_Good(t *testing.T) {
t.Run("all run concurrently", func(t *testing.T) {
runner := newTestRunner(t)
@ -102,7 +102,7 @@ func TestRunner_RunParallel(t *testing.T) {
})
}
func TestRunner_RunAll(t *testing.T) {
func TestRunner_RunAll_Good(t *testing.T) {
t.Run("respects dependencies", func(t *testing.T) {
runner := newTestRunner(t)
@ -150,7 +150,7 @@ func TestRunner_RunAll(t *testing.T) {
})
}
func TestRunner_RunAll_CircularDeps(t *testing.T) {
func TestRunner_RunAll_Bad_CircularDeps(t *testing.T) {
t.Run("circular dependency counts as failed", func(t *testing.T) {
runner := newTestRunner(t)
@ -166,7 +166,7 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) {
})
}
func TestRunResult_Passed(t *testing.T) {
func TestRunResult_Passed_Good(t *testing.T) {
t.Run("success", func(t *testing.T) {
r := RunResult{ExitCode: 0}
assert.True(t, r.Passed())

View file

@ -3,8 +3,9 @@ package process
import (
"bufio"
"context"
"io"
"os"
"os/exec"
"strconv"
"sync"
"sync/atomic"
"syscall"
@ -14,6 +15,12 @@ import (
coreerr "dappco.re/go/core/log"
)
type execCmd = exec.Cmd
type streamReader interface {
Read(p []byte) (n int, err error)
}
// Default buffer size for process output (1MB).
const DefaultBufferSize = 1024 * 1024
@ -41,11 +48,10 @@ type Options struct {
BufferSize int
}
// Register is the WithService factory for go-process.
// Registers the process service with Core — OnStartup registers named Actions
// (process.run, process.start, process.kill, process.list, process.get).
// Register constructs a Service bound to the provided Core instance.
//
// core.New(core.WithService(process.Register))
// c := core.New()
// svc := process.Register(c).Value.(*process.Service)
func Register(c *core.Core) core.Result {
svc := &Service{
ServiceRuntime: core.NewServiceRuntime(c, Options{BufferSize: DefaultBufferSize}),
@ -56,11 +62,15 @@ func Register(c *core.Core) core.Result {
}
// NewService creates a process service factory for Core registration.
// Deprecated: Use Register with core.WithService(process.Register) instead.
// Deprecated: Use Register(c) to construct a Service directly.
//
// core, _ := core.New(
// core.WithName("process", process.NewService(process.Options{})),
// )
// c := core.New()
// factory := process.NewService(process.Options{})
// raw, err := factory(c)
// if err != nil {
// return nil, err
// }
// svc := raw.(*process.Service)
func NewService(opts Options) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
if opts.BufferSize == 0 {
@ -75,16 +85,8 @@ func NewService(opts Options) func(*core.Core) (any, error) {
}
}
// OnStartup implements core.Startable — registers named Actions.
//
// c.Process().Run(ctx, "git", "log") // → calls process.run Action
// OnStartup implements core.Startable.
func (s *Service) OnStartup(ctx context.Context) core.Result {
c := s.Core()
c.Action("process.run", s.handleRun)
c.Action("process.start", s.handleStart)
c.Action("process.kill", s.handleKill)
c.Action("process.list", s.handleList)
c.Action("process.get", s.handleGet)
return core.Result{OK: true}
}
@ -124,7 +126,7 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) cor
// r := svc.StartWithOptions(ctx, process.RunOptions{Command: "go", Args: []string{"test", "./..."}})
// if r.OK { proc := r.Value.(*Process) }
func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Result {
id := core.ID()
id := s.nextProcessID()
// Detached processes use Background context so they survive parent death
parentCtx := ctx
@ -132,7 +134,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re
parentCtx = context.Background()
}
procCtx, cancel := context.WithCancel(parentCtx)
cmd := exec.CommandContext(procCtx, opts.Command, opts.Args...)
cmd := execCommandContext(procCtx, opts.Command, opts.Args...)
if opts.Dir != "" {
cmd.Dir = opts.Dir
@ -271,7 +273,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re
}
// streamOutput reads from a pipe and broadcasts lines via ACTION.
func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) {
func (s *Service) streamOutput(proc *Process, r streamReader, stream Stream) {
scanner := bufio.NewScanner(r)
// Increase buffer for long lines
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
@ -423,17 +425,8 @@ func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Resu
return core.Result{Value: proc.Output(), OK: proc.ExitCode == 0}
}
// --- Named Action Handlers ---
// These are registered during OnStartup and called via c.Process() sugar.
// c.Process().Run(ctx, "git", "log") → c.Action("process.run").Run(ctx, opts)
// --- Internal Request Helpers ---
// handleRun executes a command synchronously and returns the output.
//
// r := c.Action("process.run").Run(ctx, core.NewOptions(
// core.Option{Key: "command", Value: "git"},
// core.Option{Key: "args", Value: []string{"log"}},
// core.Option{Key: "dir", Value: "/repo"},
// ))
func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result {
command := opts.String("command")
if command == "" {
@ -458,13 +451,6 @@ func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result
return s.RunWithOptions(ctx, runOpts)
}
// handleStart spawns a detached/background process and returns the process ID.
//
// r := c.Action("process.start").Run(ctx, core.NewOptions(
// core.Option{Key: "command", Value: "docker"},
// core.Option{Key: "args", Value: []string{"run", "nginx"}},
// ))
// id := r.Value.(string)
func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result {
command := opts.String("command")
if command == "" {
@ -488,11 +474,6 @@ func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Resul
return core.Result{Value: r.Value.(*Process).ID, OK: true}
}
// handleKill terminates a process by ID.
//
// r := c.Action("process.kill").Run(ctx, core.NewOptions(
// core.Option{Key: "id", Value: "id-42-a3f2b1"},
// ))
func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result {
id := opts.String("id")
if id != "" {
@ -504,10 +485,6 @@ func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result
return core.Result{Value: coreerr.E("process.kill", "id is required", nil), OK: false}
}
// handleList returns the IDs of all managed processes.
//
// r := c.Action("process.list").Run(ctx, core.NewOptions())
// ids := r.Value.([]string)
func (s *Service) handleList(ctx context.Context, opts core.Options) core.Result {
s.mu.RLock()
defer s.mu.RUnlock()
@ -519,12 +496,50 @@ func (s *Service) handleList(ctx context.Context, opts core.Options) core.Result
return core.Result{Value: ids, OK: true}
}
// handleGet returns process info by ID.
//
// r := c.Action("process.get").Run(ctx, core.NewOptions(
// core.Option{Key: "id", Value: "id-42-a3f2b1"},
// ))
// info := r.Value.(process.Info)
// Signal sends a signal to the process.
func (p *Process) Signal(sig os.Signal) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.Status != StatusRunning {
return ErrProcessNotRunning
}
if p.cmd == nil || p.cmd.Process == nil {
return nil
}
return p.cmd.Process.Signal(sig)
}
func execCommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, name, args...)
}
func execLookPath(name string) (string, error) {
return exec.LookPath(name)
}
func currentPID() int {
return os.Getpid()
}
func processHandle(pid int) (*os.Process, error) {
return os.FindProcess(pid)
}
func userHomeDir() (string, error) {
return os.UserHomeDir()
}
func tempDir() string {
return os.TempDir()
}
func isNotExist(err error) bool {
return os.IsNotExist(err)
}
func (s *Service) handleGet(ctx context.Context, opts core.Options) core.Result {
id := opts.String("id")
proc, err := s.Get(id)
@ -533,3 +548,7 @@ func (s *Service) handleGet(ctx context.Context, opts core.Options) core.Result
}
return core.Result{Value: proc.Info(), OK: true}
}
func (s *Service) nextProcessID() string {
return "proc-" + strconv.FormatUint(s.idCounter.Add(1), 10)
}

View file

@ -31,7 +31,7 @@ func startProc(t *testing.T, svc *Service, ctx context.Context, command string,
return r.Value.(*Process)
}
func TestService_Start(t *testing.T) {
func TestService_Start_Good(t *testing.T) {
t.Run("echo command", func(t *testing.T) {
svc, _ := newTestService(t)
@ -153,7 +153,7 @@ func TestService_Start(t *testing.T) {
})
}
func TestService_Run(t *testing.T) {
func TestService_Run_Good(t *testing.T) {
t.Run("returns output", func(t *testing.T) {
svc, _ := newTestService(t)
@ -170,7 +170,7 @@ func TestService_Run(t *testing.T) {
})
}
func TestService_Actions(t *testing.T) {
func TestService_Actions_Good(t *testing.T) {
t.Run("broadcasts events", func(t *testing.T) {
c := framework.New()
@ -225,7 +225,7 @@ func TestService_Actions(t *testing.T) {
})
}
func TestService_List(t *testing.T) {
func TestService_List_Good(t *testing.T) {
t.Run("tracks processes", func(t *testing.T) {
svc, _ := newTestService(t)
@ -258,7 +258,7 @@ func TestService_List(t *testing.T) {
})
}
func TestService_Remove(t *testing.T) {
func TestService_Remove_Good(t *testing.T) {
t.Run("removes completed process", func(t *testing.T) {
svc, _ := newTestService(t)
@ -288,7 +288,7 @@ func TestService_Remove(t *testing.T) {
})
}
func TestService_Clear(t *testing.T) {
func TestService_Clear_Good(t *testing.T) {
t.Run("clears completed processes", func(t *testing.T) {
svc, _ := newTestService(t)
@ -306,7 +306,7 @@ func TestService_Clear(t *testing.T) {
})
}
func TestService_Kill(t *testing.T) {
func TestService_Kill_Good(t *testing.T) {
t.Run("kills running process", func(t *testing.T) {
svc, _ := newTestService(t)
@ -333,7 +333,7 @@ func TestService_Kill(t *testing.T) {
})
}
func TestService_Output(t *testing.T) {
func TestService_Output_Good(t *testing.T) {
t.Run("returns captured output", func(t *testing.T) {
svc, _ := newTestService(t)
@ -353,7 +353,7 @@ func TestService_Output(t *testing.T) {
})
}
func TestService_OnShutdown(t *testing.T) {
func TestService_OnShutdown_Good(t *testing.T) {
t.Run("kills all running processes", func(t *testing.T) {
svc, _ := newTestService(t)
@ -382,21 +382,15 @@ func TestService_OnShutdown(t *testing.T) {
})
}
func TestService_OnStartup(t *testing.T) {
t.Run("registers named actions", func(t *testing.T) {
svc, c := newTestService(t)
func TestService_OnStartup_Good(t *testing.T) {
t.Run("returns ok", func(t *testing.T) {
svc, _ := newTestService(t)
r := svc.OnStartup(context.Background())
assert.True(t, r.OK)
assert.True(t, c.Action("process.run").Exists())
assert.True(t, c.Action("process.start").Exists())
assert.True(t, c.Action("process.kill").Exists())
assert.True(t, c.Action("process.list").Exists())
assert.True(t, c.Action("process.get").Exists())
})
}
func TestService_RunWithOptions(t *testing.T) {
func TestService_RunWithOptions_Good(t *testing.T) {
t.Run("returns output on success", func(t *testing.T) {
svc, _ := newTestService(t)
@ -419,7 +413,7 @@ func TestService_RunWithOptions(t *testing.T) {
})
}
func TestService_Running(t *testing.T) {
func TestService_Running_Good(t *testing.T) {
t.Run("returns only running processes", func(t *testing.T) {
svc, _ := newTestService(t)

View file

@ -5,30 +5,29 @@
//
// # Getting Started
//
// // Register with Core
// core, _ := framework.New(
// framework.WithName("process", process.NewService(process.Options{})),
// )
//
// // Get service and run a process
// svc, err := framework.ServiceFor[*process.Service](core, "process")
// c := core.New()
// factory := process.NewService(process.Options{})
// raw, err := factory(c)
// if err != nil {
// return err
// }
// proc, err := svc.Start(ctx, "go", "test", "./...")
//
// svc := raw.(*process.Service)
// r := svc.Start(ctx, "go", "test", "./...")
// proc := r.Value.(*process.Process)
//
// # Listening for Events
//
// Process events are broadcast via Core.ACTION:
//
// core.RegisterAction(func(c *framework.Core, msg framework.Message) error {
// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
// switch m := msg.(type) {
// case process.ActionProcessOutput:
// fmt.Print(m.Line)
// case process.ActionProcessExited:
// fmt.Printf("Exit code: %d\n", m.ExitCode)
// }
// return nil
// return core.Result{OK: true}
// })
package process