fix(ax): finish v0.8.0 polish pass
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
e9bb6a8968
commit
62623ce0d6
20 changed files with 388 additions and 110 deletions
|
|
@ -4,6 +4,8 @@ import "sync"
|
|||
|
||||
// RingBuffer is a fixed-size circular buffer that overwrites old data.
|
||||
// Thread-safe for concurrent reads and writes.
|
||||
//
|
||||
// rb := process.NewRingBuffer(1024)
|
||||
type RingBuffer struct {
|
||||
data []byte
|
||||
size int
|
||||
|
|
@ -14,6 +16,8 @@ type RingBuffer struct {
|
|||
}
|
||||
|
||||
// NewRingBuffer creates a ring buffer with the given capacity.
|
||||
//
|
||||
// rb := process.NewRingBuffer(256)
|
||||
func NewRingBuffer(size int) *RingBuffer {
|
||||
return &RingBuffer{
|
||||
data: make([]byte, size),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRingBuffer_Good(t *testing.T) {
|
||||
func TestRingBuffer_Basics_Good(t *testing.T) {
|
||||
t.Run("write and read", func(t *testing.T) {
|
||||
rb := NewRingBuffer(10)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import (
|
|||
)
|
||||
|
||||
// DaemonOptions configures daemon mode execution.
|
||||
//
|
||||
// opts := process.DaemonOptions{PIDFile: "/tmp/process.pid", HealthAddr: "127.0.0.1:0"}
|
||||
type DaemonOptions struct {
|
||||
// PIDFile path for single-instance enforcement.
|
||||
// Leave empty to skip PID file management.
|
||||
|
|
@ -35,6 +37,8 @@ type DaemonOptions struct {
|
|||
}
|
||||
|
||||
// Daemon manages daemon lifecycle: PID file, health server, graceful shutdown.
|
||||
//
|
||||
// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"})
|
||||
type Daemon struct {
|
||||
opts DaemonOptions
|
||||
pid *PIDFile
|
||||
|
|
@ -44,6 +48,8 @@ type Daemon struct {
|
|||
}
|
||||
|
||||
// NewDaemon creates a daemon runner with the given options.
|
||||
//
|
||||
// daemon := process.NewDaemon(process.DaemonOptions{PIDFile: "/tmp/process.pid"})
|
||||
func NewDaemon(opts DaemonOptions) *Daemon {
|
||||
if opts.ShutdownTimeout == 0 {
|
||||
opts.ShutdownTimeout = 30 * time.Second
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ import (
|
|||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDaemon_Lifecycle_Good(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
pidPath := core.JoinPath(t.TempDir(), "test.pid")
|
||||
|
||||
d := NewDaemon(DaemonOptions{
|
||||
PIDFile: pidPath,
|
||||
|
|
@ -36,7 +36,7 @@ func TestDaemon_Lifecycle_Good(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDaemon_Start_Bad_AlreadyRunning(t *testing.T) {
|
||||
func TestDaemon_AlreadyRunning_Bad(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
|
@ -50,7 +50,7 @@ func TestDaemon_Start_Bad_AlreadyRunning(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "already running")
|
||||
}
|
||||
|
||||
func TestDaemon_Run_Bad_NotStarted(t *testing.T) {
|
||||
func TestDaemon_RunUnstarted_Bad(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -83,17 +83,17 @@ func TestDaemon_SetReady_Good(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestDaemon_HealthAddr_Good_EmptyWhenDisabled(t *testing.T) {
|
||||
func TestDaemon_HealthAddrDisabled_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Empty(t, d.HealthAddr())
|
||||
}
|
||||
|
||||
func TestDaemon_ShutdownTimeout_Good_Default(t *testing.T) {
|
||||
func TestDaemon_DefaultTimeout_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
|
||||
}
|
||||
|
||||
func TestDaemon_Run_Good_BlocksUntilCancelled(t *testing.T) {
|
||||
func TestDaemon_RunBlocking_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
|
@ -126,7 +126,7 @@ func TestDaemon_Run_Good_BlocksUntilCancelled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDaemon_Stop_Good_Idempotent(t *testing.T) {
|
||||
func TestDaemon_StopIdempotent_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
|
||||
// Stop without Start should be a no-op
|
||||
|
|
@ -134,9 +134,9 @@ func TestDaemon_Stop_Good_Idempotent(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDaemon_Registry_Good_AutoRegisters(t *testing.T) {
|
||||
func TestDaemon_AutoRegister_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(filepath.Join(dir, "daemons"))
|
||||
reg := NewRegistry(core.JoinPath(dir, "daemons"))
|
||||
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// Package exec provides a small command wrapper around `os/exec` with
|
||||
// structured logging hooks.
|
||||
//
|
||||
// ctx := context.Background()
|
||||
// out, err := exec.Command(ctx, "echo", "hello").Output()
|
||||
package exec
|
||||
|
|
|
|||
31
exec/exec.go
31
exec/exec.go
|
|
@ -3,16 +3,16 @@ package exec
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// Options configures command execution.
|
||||
//
|
||||
// opts := exec.Options{Dir: "/workspace", Env: []string{"CI=1"}}
|
||||
type Options struct {
|
||||
Dir string
|
||||
Env []string
|
||||
|
|
@ -24,6 +24,8 @@ type Options struct {
|
|||
}
|
||||
|
||||
// Command wraps `os/exec.Command` with logging and context.
|
||||
//
|
||||
// cmd := exec.Command(ctx, "git", "status").WithDir("/workspace")
|
||||
func Command(ctx context.Context, name string, args ...string) *Cmd {
|
||||
return &Cmd{
|
||||
name: name,
|
||||
|
|
@ -144,22 +146,23 @@ func (c *Cmd) prepare() {
|
|||
|
||||
// RunQuiet executes the command suppressing stdout unless there is an error.
|
||||
// Useful for internal commands.
|
||||
//
|
||||
// _ = exec.RunQuiet(ctx, "go", "test", "./...")
|
||||
func RunQuiet(ctx context.Context, name string, args ...string) error {
|
||||
var stderr bytes.Buffer
|
||||
cmd := Command(ctx, name, args...).WithStderr(&stderr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Include stderr in error message
|
||||
return coreerr.E("RunQuiet", strings.TrimSpace(stderr.String()), err)
|
||||
return core.E("RunQuiet", core.Trim(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wrapError(caller string, err error, name string, args []string) error {
|
||||
cmdStr := name + " " + strings.Join(args, " ")
|
||||
cmdStr := commandString(name, args)
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return coreerr.E(caller, fmt.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err)
|
||||
return core.E(caller, core.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err)
|
||||
}
|
||||
return coreerr.E(caller, fmt.Sprintf("failed to execute %q", cmdStr), err)
|
||||
return core.E(caller, core.Sprintf("failed to execute %q", cmdStr), err)
|
||||
}
|
||||
|
||||
func (c *Cmd) getLogger() Logger {
|
||||
|
|
@ -170,9 +173,17 @@ func (c *Cmd) getLogger() Logger {
|
|||
}
|
||||
|
||||
func (c *Cmd) logDebug(msg string) {
|
||||
c.getLogger().Debug(msg, "cmd", c.name, "args", strings.Join(c.args, " "))
|
||||
c.getLogger().Debug(msg, "cmd", c.name, "args", core.Join(" ", c.args...))
|
||||
}
|
||||
|
||||
func (c *Cmd) logError(msg string, err error) {
|
||||
c.getLogger().Error(msg, "cmd", c.name, "args", strings.Join(c.args, " "), "err", err)
|
||||
c.getLogger().Error(msg, "cmd", c.name, "args", core.Join(" ", c.args...), "err", err)
|
||||
}
|
||||
|
||||
func commandString(name string, args []string) string {
|
||||
if len(args) == 0 {
|
||||
return name
|
||||
}
|
||||
parts := append([]string{name}, args...)
|
||||
return core.Join(" ", parts...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ package exec_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/process/exec"
|
||||
)
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ func (m *mockLogger) Error(msg string, keyvals ...any) {
|
|||
m.errorCalls = append(m.errorCalls, logCall{msg, keyvals})
|
||||
}
|
||||
|
||||
func TestCommand_Run_Good_LogsDebug(t *testing.T) {
|
||||
func TestCommand_Run_Good(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
ctx := context.Background()
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ func TestCommand_Run_Good_LogsDebug(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCommand_Run_Bad_LogsError(t *testing.T) {
|
||||
func TestCommand_Run_Bad(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
ctx := context.Background()
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ func TestCommand_Output_Good(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "test" {
|
||||
if core.Trim(string(out)) != "test" {
|
||||
t.Errorf("expected 'test', got %q", string(out))
|
||||
}
|
||||
if len(logger.debugCalls) != 1 {
|
||||
|
|
@ -99,7 +99,7 @@ func TestCommand_CombinedOutput_Good(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "combined" {
|
||||
if core.Trim(string(out)) != "combined" {
|
||||
t.Errorf("expected 'combined', got %q", string(out))
|
||||
}
|
||||
if len(logger.debugCalls) != 1 {
|
||||
|
|
@ -107,14 +107,14 @@ func TestCommand_CombinedOutput_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNopLogger_Good(t *testing.T) {
|
||||
func TestNopLogger_Methods_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_Good(t *testing.T) {
|
||||
func TestLogger_SetDefault_Good(t *testing.T) {
|
||||
original := exec.DefaultLogger()
|
||||
defer exec.SetDefaultLogger(original)
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ func TestCommand_WithDir_Good(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(out))
|
||||
trimmed := core.Trim(string(out))
|
||||
if trimmed != "/tmp" && trimmed != "/private/tmp" {
|
||||
t.Errorf("expected /tmp or /private/tmp, got %q", trimmed)
|
||||
}
|
||||
|
|
@ -171,31 +171,32 @@ func TestCommand_WithEnv_Good(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "exec_val" {
|
||||
if core.Trim(string(out)) != "exec_val" {
|
||||
t.Errorf("expected 'exec_val', got %q", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_WithStdinStdoutStderr_Good(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
input := strings.NewReader("piped input\n")
|
||||
var stdout, stderr strings.Builder
|
||||
input := core.NewReader("piped input\n")
|
||||
stdout := core.NewBuilder()
|
||||
stderr := core.NewBuilder()
|
||||
|
||||
err := exec.Command(ctx, "cat").
|
||||
WithStdin(input).
|
||||
WithStdout(&stdout).
|
||||
WithStderr(&stderr).
|
||||
WithStdout(stdout).
|
||||
WithStderr(stderr).
|
||||
WithLogger(&mockLogger{}).
|
||||
Run()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(stdout.String()) != "piped input" {
|
||||
if core.Trim(stdout.String()) != "piped input" {
|
||||
t.Errorf("expected 'piped input', got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQuiet_Good(t *testing.T) {
|
||||
func TestRunQuiet_Command_Good(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := exec.RunQuiet(ctx, "echo", "quiet")
|
||||
if err != nil {
|
||||
|
|
@ -203,7 +204,7 @@ func TestRunQuiet_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunQuiet_Bad(t *testing.T) {
|
||||
func TestRunQuiet_Command_Bad(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := exec.RunQuiet(ctx, "sh", "-c", "echo fail >&2; exit 1")
|
||||
if err == nil {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package exec
|
|||
|
||||
// Logger interface for command execution logging.
|
||||
// Compatible with pkg/log.Logger and other structured loggers.
|
||||
//
|
||||
// exec.SetDefaultLogger(myLogger)
|
||||
type Logger interface {
|
||||
// Debug logs a debug-level message with optional key-value pairs.
|
||||
Debug(msg string, keyvals ...any)
|
||||
|
|
@ -10,6 +12,8 @@ type Logger interface {
|
|||
}
|
||||
|
||||
// NopLogger is a no-op logger that discards all messages.
|
||||
//
|
||||
// var logger exec.NopLogger
|
||||
type NopLogger struct{}
|
||||
|
||||
// Debug discards the message (no-op implementation).
|
||||
|
|
@ -22,6 +26,8 @@ var defaultLogger Logger = NopLogger{}
|
|||
|
||||
// SetDefaultLogger sets the package-level default logger.
|
||||
// Commands without an explicit logger will use this.
|
||||
//
|
||||
// exec.SetDefaultLogger(myLogger)
|
||||
func SetDefaultLogger(l Logger) {
|
||||
if l == nil {
|
||||
l = NopLogger{}
|
||||
|
|
@ -30,6 +36,8 @@ func SetDefaultLogger(l Logger) {
|
|||
}
|
||||
|
||||
// DefaultLogger returns the current default logger.
|
||||
//
|
||||
// logger := exec.DefaultLogger()
|
||||
func DefaultLogger() Logger {
|
||||
return defaultLogger
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGlobal_Default_Bad_NotInitialized(t *testing.T) {
|
||||
func TestGlobal_NotInitialized_Bad(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
|
|
@ -72,7 +72,7 @@ func TestGlobal_SetDefault_Good(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestGlobal_Default_Good_Concurrent(t *testing.T) {
|
||||
func TestGlobal_DefaultConcurrent_Good(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
|
|
@ -97,7 +97,7 @@ func TestGlobal_Default_Good_Concurrent(t *testing.T) {
|
|||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestGlobal_SetDefault_Good_Concurrent(t *testing.T) {
|
||||
func TestGlobal_SetDefaultConcurrent_Good(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
|
|
@ -134,7 +134,7 @@ func TestGlobal_SetDefault_Good_Concurrent(t *testing.T) {
|
|||
assert.True(t, found, "Default should be one of the set services")
|
||||
}
|
||||
|
||||
func TestGlobal_Operations_Good_Concurrent(t *testing.T) {
|
||||
func TestGlobal_ConcurrentOps_Good(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,13 @@ import (
|
|||
)
|
||||
|
||||
// HealthCheck is a function that returns nil if healthy.
|
||||
//
|
||||
// check := process.HealthCheck(func() error { return nil })
|
||||
type HealthCheck func() error
|
||||
|
||||
// HealthServer provides HTTP /health and /ready endpoints for process monitoring.
|
||||
//
|
||||
// hs := process.NewHealthServer("127.0.0.1:0")
|
||||
type HealthServer struct {
|
||||
addr string
|
||||
server *http.Server
|
||||
|
|
@ -25,6 +29,8 @@ type HealthServer struct {
|
|||
}
|
||||
|
||||
// NewHealthServer creates a health check server on the given address.
|
||||
//
|
||||
// hs := process.NewHealthServer("127.0.0.1:0")
|
||||
func NewHealthServer(addr string) *HealthServer {
|
||||
return &HealthServer{
|
||||
addr: addr,
|
||||
|
|
@ -115,6 +121,8 @@ func (h *HealthServer) Addr() string {
|
|||
|
||||
// WaitForHealth polls a health endpoint until it responds 200 or the timeout
|
||||
// (in milliseconds) expires. Returns true if healthy, false on timeout.
|
||||
//
|
||||
// ok := process.WaitForHealth("127.0.0.1:9000", 2_000)
|
||||
func WaitForHealth(addr string, timeoutMs int) bool {
|
||||
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
|
||||
url := core.Concat("http://", addr, "/health")
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ func TestHealthServer_WithChecks_Good(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestWaitForHealth_Good_Reachable(t *testing.T) {
|
||||
func TestWaitForHealth_Reachable_Good(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_Good_Reachable(t *testing.T) {
|
|||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestWaitForHealth_Bad_Unreachable(t *testing.T) {
|
||||
func TestWaitForHealth_Unreachable_Bad(t *testing.T) {
|
||||
ok := WaitForHealth("127.0.0.1:19999", 500)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -2,15 +2,15 @@ package process
|
|||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPIDFile_Acquire_Good(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
pidPath := core.JoinPath(t.TempDir(), "test.pid")
|
||||
pid := NewPIDFile(pidPath)
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -23,8 +23,8 @@ func TestPIDFile_Acquire_Good(t *testing.T) {
|
|||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestPIDFile_Acquire_Good_StalePID(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "stale.pid")
|
||||
func TestPIDFile_AcquireStale_Good(t *testing.T) {
|
||||
pidPath := core.JoinPath(t.TempDir(), "stale.pid")
|
||||
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
|
||||
pid := NewPIDFile(pidPath)
|
||||
err := pid.Acquire()
|
||||
|
|
@ -33,8 +33,8 @@ func TestPIDFile_Acquire_Good_StalePID(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPIDFile_Acquire_Good_CreatesParentDirectory(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid")
|
||||
func TestPIDFile_CreateDirectory_Good(t *testing.T) {
|
||||
pidPath := core.JoinPath(t.TempDir(), "subdir", "nested", "test.pid")
|
||||
pid := NewPIDFile(pidPath)
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -47,22 +47,22 @@ func TestPIDFile_Path_Good(t *testing.T) {
|
|||
assert.Equal(t, "/tmp/test.pid", pid.Path())
|
||||
}
|
||||
|
||||
func TestReadPID_Bad_Missing(t *testing.T) {
|
||||
func TestReadPID_Missing_Bad(t *testing.T) {
|
||||
pid, running := ReadPID("/nonexistent/path.pid")
|
||||
assert.Equal(t, 0, pid)
|
||||
assert.False(t, running)
|
||||
}
|
||||
|
||||
func TestReadPID_Bad_InvalidContent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.pid")
|
||||
func TestReadPID_Invalid_Bad(t *testing.T) {
|
||||
path := core.JoinPath(t.TempDir(), "bad.pid")
|
||||
require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644))
|
||||
pid, running := ReadPID(path)
|
||||
assert.Equal(t, 0, pid)
|
||||
assert.False(t, running)
|
||||
}
|
||||
|
||||
func TestReadPID_Bad_StalePID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "stale.pid")
|
||||
func TestReadPID_Stale_Bad(t *testing.T) {
|
||||
path := core.JoinPath(t.TempDir(), "stale.pid")
|
||||
require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644))
|
||||
pid, running := ReadPID(path)
|
||||
assert.Equal(t, 999999999, pid)
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
goapi "forge.lthn.ai/core/api"
|
||||
process "dappco.re/go/core/process"
|
||||
processapi "dappco.re/go/core/process/pkg/api"
|
||||
goapi "forge.lthn.ai/core/api"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -65,10 +64,8 @@ func TestProcessProvider_ListDaemons_Good(t *testing.T) {
|
|||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp goapi.Response[[]any]
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Success)
|
||||
body := w.Body.String()
|
||||
assert.NotEmpty(t, body)
|
||||
}
|
||||
|
||||
func TestProcessProvider_GetDaemon_Bad(t *testing.T) {
|
||||
|
|
@ -95,7 +92,7 @@ func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) {
|
|||
assert.Equal(t, "process", engine.Groups()[0].Name())
|
||||
}
|
||||
|
||||
func TestProcessProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) {
|
||||
func TestProcessProvider_StreamGroup_Good(t *testing.T) {
|
||||
p := processapi.NewProvider(nil, nil)
|
||||
|
||||
engine, err := goapi.New()
|
||||
|
|
|
|||
10
program.go
10
program.go
|
|
@ -9,11 +9,13 @@ import (
|
|||
)
|
||||
|
||||
// ErrProgramNotFound is returned when Find cannot locate the binary on PATH.
|
||||
// Callers may use errors.Is to detect this condition.
|
||||
// Callers may use core.Is to detect this condition.
|
||||
var ErrProgramNotFound = coreerr.E("", "program: binary not found in PATH", nil)
|
||||
|
||||
// Program represents a named executable located on the system PATH.
|
||||
// Create one with a Name, call Find to resolve its path, then Run or RunDir.
|
||||
//
|
||||
// p := &process.Program{Name: "go"}
|
||||
type Program struct {
|
||||
// Name is the binary name (e.g. "go", "node", "git").
|
||||
Name string
|
||||
|
|
@ -24,6 +26,8 @@ type Program struct {
|
|||
|
||||
// Find resolves the program's absolute path using exec.LookPath.
|
||||
// Returns ErrProgramNotFound (wrapped) if the binary is not on PATH.
|
||||
//
|
||||
// err := p.Find()
|
||||
func (p *Program) Find() error {
|
||||
if p.Name == "" {
|
||||
return coreerr.E("Program.Find", "program name is empty", nil)
|
||||
|
|
@ -38,6 +42,8 @@ func (p *Program) Find() error {
|
|||
|
||||
// Run executes the program with args in the current working directory.
|
||||
// Returns trimmed combined stdout+stderr output and any error.
|
||||
//
|
||||
// out, err := p.Run(ctx, "version")
|
||||
func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
|
||||
return p.RunDir(ctx, "", args...)
|
||||
}
|
||||
|
|
@ -45,6 +51,8 @@ func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
|
|||
// RunDir executes the program with args in dir.
|
||||
// Returns trimmed combined stdout+stderr output and any error.
|
||||
// If dir is empty, the process inherits the caller's working directory.
|
||||
//
|
||||
// out, err := p.RunDir(ctx, "/workspace", "test", "./...")
|
||||
func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
binary := p.Path
|
||||
if binary == "" {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ package process_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
|
|
@ -19,25 +20,25 @@ func testCtx(t *testing.T) context.Context {
|
|||
return ctx
|
||||
}
|
||||
|
||||
func TestProgram_Find_Good_KnownBinary(t *testing.T) {
|
||||
func TestProgram_Find_Good(t *testing.T) {
|
||||
p := &process.Program{Name: "echo"}
|
||||
require.NoError(t, p.Find())
|
||||
assert.NotEmpty(t, p.Path)
|
||||
}
|
||||
|
||||
func TestProgram_Find_Bad_UnknownBinary(t *testing.T) {
|
||||
func TestProgram_FindUnknown_Bad(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_Bad_EmptyName(t *testing.T) {
|
||||
func TestProgram_FindEmpty_Bad(t *testing.T) {
|
||||
p := &process.Program{}
|
||||
require.Error(t, p.Find())
|
||||
}
|
||||
|
||||
func TestProgram_Run_Good_ReturnsOutput(t *testing.T) {
|
||||
func TestProgram_Run_Good(t *testing.T) {
|
||||
p := &process.Program{Name: "echo"}
|
||||
require.NoError(t, p.Find())
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ func TestProgram_Run_Good_ReturnsOutput(t *testing.T) {
|
|||
assert.Equal(t, "hello", out)
|
||||
}
|
||||
|
||||
func TestProgram_Run_Good_FallsBackToName(t *testing.T) {
|
||||
func TestProgram_RunFallback_Good(t *testing.T) {
|
||||
// Path is empty; RunDir should fall back to Name for OS PATH resolution.
|
||||
p := &process.Program{Name: "echo"}
|
||||
|
||||
|
|
@ -55,7 +56,7 @@ func TestProgram_Run_Good_FallsBackToName(t *testing.T) {
|
|||
assert.Equal(t, "fallback", out)
|
||||
}
|
||||
|
||||
func TestProgram_RunDir_Good_UsesDirectory(t *testing.T) {
|
||||
func TestProgram_RunDir_Good(t *testing.T) {
|
||||
p := &process.Program{Name: "pwd"}
|
||||
require.NoError(t, p.Find())
|
||||
|
||||
|
|
@ -63,15 +64,14 @@ func TestProgram_RunDir_Good_UsesDirectory(t *testing.T) {
|
|||
|
||||
out, err := p.RunDir(testCtx(t), dir)
|
||||
require.NoError(t, err)
|
||||
// Resolve symlinks on both sides for portability (macOS uses /private/ prefix).
|
||||
canonicalDir, err := filepath.EvalSymlinks(dir)
|
||||
dirInfo, err := os.Stat(dir)
|
||||
require.NoError(t, err)
|
||||
canonicalOut, err := filepath.EvalSymlinks(out)
|
||||
outInfo, err := os.Stat(core.Trim(out))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, canonicalDir, canonicalOut)
|
||||
assert.True(t, os.SameFile(dirInfo, outInfo))
|
||||
}
|
||||
|
||||
func TestProgram_Run_Bad_FailingCommand(t *testing.T) {
|
||||
func TestProgram_RunFailure_Bad(t *testing.T) {
|
||||
p := &process.Program{Name: "false"}
|
||||
require.NoError(t, p.Find())
|
||||
|
||||
|
|
|
|||
264
registry.go
264
registry.go
|
|
@ -2,16 +2,18 @@ package process
|
|||
|
||||
import (
|
||||
"path"
|
||||
"strconv"
|
||||
"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.
|
||||
//
|
||||
// entry := process.DaemonEntry{Code: "myapp", Daemon: "serve", PID: 1234}
|
||||
type DaemonEntry struct {
|
||||
Code string `json:"code"`
|
||||
Daemon string `json:"daemon"`
|
||||
|
|
@ -23,16 +25,22 @@ type DaemonEntry struct {
|
|||
}
|
||||
|
||||
// Registry tracks running daemons via JSON files in a directory.
|
||||
//
|
||||
// reg := process.NewRegistry("/tmp/process-daemons")
|
||||
type Registry struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewRegistry creates a registry backed by the given directory.
|
||||
//
|
||||
// reg := process.NewRegistry("/tmp/process-daemons")
|
||||
func NewRegistry(dir string) *Registry {
|
||||
return &Registry{dir: dir}
|
||||
}
|
||||
|
||||
// DefaultRegistry returns a registry using ~/.core/daemons/.
|
||||
//
|
||||
// reg := process.DefaultRegistry()
|
||||
func DefaultRegistry() *Registry {
|
||||
home, err := userHomeDir()
|
||||
if err != nil {
|
||||
|
|
@ -53,12 +61,12 @@ func (r *Registry) Register(entry DaemonEntry) error {
|
|||
return coreerr.E("Registry.Register", "failed to create registry directory", err)
|
||||
}
|
||||
|
||||
data, err := jsonx.MarshalIndent(entry)
|
||||
data, err := marshalDaemonEntry(entry)
|
||||
if err != nil {
|
||||
return coreerr.E("Registry.Register", "failed to marshal entry", err)
|
||||
}
|
||||
|
||||
if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), string(data)); err != nil {
|
||||
if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), data); err != nil {
|
||||
return coreerr.E("Registry.Register", "failed to write entry file", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -82,8 +90,8 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
|
|||
return nil, false
|
||||
}
|
||||
|
||||
var entry DaemonEntry
|
||||
if err := jsonx.Unmarshal(data, &entry); err != nil {
|
||||
entry, err := unmarshalDaemonEntry(data)
|
||||
if err != nil {
|
||||
_ = coreio.Local.Delete(path)
|
||||
return nil, false
|
||||
}
|
||||
|
|
@ -118,8 +126,8 @@ func (r *Registry) List() ([]DaemonEntry, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
var entry DaemonEntry
|
||||
if err := jsonx.Unmarshal(data, &entry); err != nil {
|
||||
entry, err := unmarshalDaemonEntry(data)
|
||||
if err != nil {
|
||||
_ = coreio.Local.Delete(path)
|
||||
continue
|
||||
}
|
||||
|
|
@ -164,3 +172,245 @@ func sanitizeRegistryComponent(value string) string {
|
|||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func marshalDaemonEntry(entry DaemonEntry) (string, error) {
|
||||
fields := []struct {
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{key: "code", value: quoteJSONString(entry.Code)},
|
||||
{key: "daemon", value: quoteJSONString(entry.Daemon)},
|
||||
{key: "pid", value: strconv.Itoa(entry.PID)},
|
||||
}
|
||||
|
||||
if entry.Health != "" {
|
||||
fields = append(fields, struct {
|
||||
key string
|
||||
value string
|
||||
}{key: "health", value: quoteJSONString(entry.Health)})
|
||||
}
|
||||
if entry.Project != "" {
|
||||
fields = append(fields, struct {
|
||||
key string
|
||||
value string
|
||||
}{key: "project", value: quoteJSONString(entry.Project)})
|
||||
}
|
||||
if entry.Binary != "" {
|
||||
fields = append(fields, struct {
|
||||
key string
|
||||
value string
|
||||
}{key: "binary", value: quoteJSONString(entry.Binary)})
|
||||
}
|
||||
|
||||
fields = append(fields, struct {
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
key: "started",
|
||||
value: quoteJSONString(entry.Started.Format(time.RFC3339Nano)),
|
||||
})
|
||||
|
||||
builder := core.NewBuilder()
|
||||
builder.WriteString("{\n")
|
||||
for i, field := range fields {
|
||||
builder.WriteString(core.Concat(" ", quoteJSONString(field.key), ": ", field.value))
|
||||
if i < len(fields)-1 {
|
||||
builder.WriteString(",")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("}")
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func unmarshalDaemonEntry(data string) (DaemonEntry, error) {
|
||||
values, err := parseJSONObject(data)
|
||||
if err != nil {
|
||||
return DaemonEntry{}, err
|
||||
}
|
||||
|
||||
entry := DaemonEntry{
|
||||
Code: values["code"],
|
||||
Daemon: values["daemon"],
|
||||
Health: values["health"],
|
||||
Project: values["project"],
|
||||
Binary: values["binary"],
|
||||
}
|
||||
|
||||
pidValue, ok := values["pid"]
|
||||
if !ok {
|
||||
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing pid", nil)
|
||||
}
|
||||
entry.PID, err = strconv.Atoi(pidValue)
|
||||
if err != nil {
|
||||
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid pid", err)
|
||||
}
|
||||
|
||||
startedValue, ok := values["started"]
|
||||
if !ok {
|
||||
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing started", nil)
|
||||
}
|
||||
entry.Started, err = time.Parse(time.RFC3339Nano, startedValue)
|
||||
if err != nil {
|
||||
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid started timestamp", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func parseJSONObject(data string) (map[string]string, error) {
|
||||
trimmed := core.Trim(data)
|
||||
if trimmed == "" {
|
||||
return nil, core.E("Registry.parseJSONObject", "empty JSON object", nil)
|
||||
}
|
||||
if trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' {
|
||||
return nil, core.E("Registry.parseJSONObject", "invalid JSON object", nil)
|
||||
}
|
||||
|
||||
values := make(map[string]string)
|
||||
index := skipJSONSpace(trimmed, 1)
|
||||
for index < len(trimmed) {
|
||||
if trimmed[index] == '}' {
|
||||
return values, nil
|
||||
}
|
||||
|
||||
key, next, err := parseJSONString(trimmed, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
index = skipJSONSpace(trimmed, next)
|
||||
if index >= len(trimmed) || trimmed[index] != ':' {
|
||||
return nil, core.E("Registry.parseJSONObject", "missing key separator", nil)
|
||||
}
|
||||
|
||||
index = skipJSONSpace(trimmed, index+1)
|
||||
if index >= len(trimmed) {
|
||||
return nil, core.E("Registry.parseJSONObject", "missing value", nil)
|
||||
}
|
||||
|
||||
var value string
|
||||
if trimmed[index] == '"' {
|
||||
value, index, err = parseJSONString(trimmed, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
start := index
|
||||
for index < len(trimmed) && trimmed[index] != ',' && trimmed[index] != '}' {
|
||||
index++
|
||||
}
|
||||
value = core.Trim(trimmed[start:index])
|
||||
}
|
||||
values[key] = value
|
||||
|
||||
index = skipJSONSpace(trimmed, index)
|
||||
if index >= len(trimmed) {
|
||||
break
|
||||
}
|
||||
if trimmed[index] == ',' {
|
||||
index = skipJSONSpace(trimmed, index+1)
|
||||
continue
|
||||
}
|
||||
if trimmed[index] == '}' {
|
||||
return values, nil
|
||||
}
|
||||
return nil, core.E("Registry.parseJSONObject", "invalid object separator", nil)
|
||||
}
|
||||
|
||||
return nil, core.E("Registry.parseJSONObject", "unterminated JSON object", nil)
|
||||
}
|
||||
|
||||
func parseJSONString(data string, start int) (string, int, error) {
|
||||
if start >= len(data) || data[start] != '"' {
|
||||
return "", 0, core.E("Registry.parseJSONString", "expected quoted string", nil)
|
||||
}
|
||||
|
||||
builder := core.NewBuilder()
|
||||
for index := start + 1; index < len(data); index++ {
|
||||
ch := data[index]
|
||||
if ch == '"' {
|
||||
return builder.String(), index + 1, nil
|
||||
}
|
||||
if ch != '\\' {
|
||||
builder.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
index++
|
||||
if index >= len(data) {
|
||||
return "", 0, core.E("Registry.parseJSONString", "unterminated escape sequence", nil)
|
||||
}
|
||||
|
||||
switch data[index] {
|
||||
case '"', '\\', '/':
|
||||
builder.WriteByte(data[index])
|
||||
case 'b':
|
||||
builder.WriteByte('\b')
|
||||
case 'f':
|
||||
builder.WriteByte('\f')
|
||||
case 'n':
|
||||
builder.WriteByte('\n')
|
||||
case 'r':
|
||||
builder.WriteByte('\r')
|
||||
case 't':
|
||||
builder.WriteByte('\t')
|
||||
case 'u':
|
||||
if index+4 >= len(data) {
|
||||
return "", 0, core.E("Registry.parseJSONString", "short unicode escape", nil)
|
||||
}
|
||||
r, err := strconv.ParseInt(data[index+1:index+5], 16, 32)
|
||||
if err != nil {
|
||||
return "", 0, core.E("Registry.parseJSONString", "invalid unicode escape", err)
|
||||
}
|
||||
builder.WriteRune(rune(r))
|
||||
index += 4
|
||||
default:
|
||||
return "", 0, core.E("Registry.parseJSONString", "invalid escape sequence", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return "", 0, core.E("Registry.parseJSONString", "unterminated string", nil)
|
||||
}
|
||||
|
||||
func skipJSONSpace(data string, index int) int {
|
||||
for index < len(data) {
|
||||
switch data[index] {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
index++
|
||||
default:
|
||||
return index
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func quoteJSONString(value string) string {
|
||||
builder := core.NewBuilder()
|
||||
builder.WriteByte('"')
|
||||
for i := 0; i < len(value); i++ {
|
||||
switch value[i] {
|
||||
case '\\', '"':
|
||||
builder.WriteByte('\\')
|
||||
builder.WriteByte(value[i])
|
||||
case '\b':
|
||||
builder.WriteString(`\b`)
|
||||
case '\f':
|
||||
builder.WriteString(`\f`)
|
||||
case '\n':
|
||||
builder.WriteString(`\n`)
|
||||
case '\r':
|
||||
builder.WriteString(`\r`)
|
||||
case '\t':
|
||||
builder.WriteString(`\t`)
|
||||
default:
|
||||
if value[i] < 0x20 {
|
||||
builder.WriteString(core.Sprintf("\\u%04x", value[i]))
|
||||
continue
|
||||
}
|
||||
builder.WriteByte(value[i])
|
||||
}
|
||||
}
|
||||
builder.WriteByte('"')
|
||||
return builder.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ package process
|
|||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -53,7 +53,7 @@ func TestRegistry_Unregister_Good(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// File should exist
|
||||
path := filepath.Join(dir, "myapp-server.json")
|
||||
path := core.JoinPath(dir, "myapp-server.json")
|
||||
_, err = os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ func TestRegistry_List_Good(t *testing.T) {
|
|||
assert.Len(t, entries, 2)
|
||||
}
|
||||
|
||||
func TestRegistry_List_Good_PrunesStale(t *testing.T) {
|
||||
func TestRegistry_PruneStale_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ func TestRegistry_List_Good_PrunesStale(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// File should exist before listing
|
||||
path := filepath.Join(dir, "dead-proc.json")
|
||||
path := core.JoinPath(dir, "dead-proc.json")
|
||||
_, err = os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ func TestRegistry_List_Good_PrunesStale(t *testing.T) {
|
|||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestRegistry_Get_Bad_NotFound(t *testing.T) {
|
||||
func TestRegistry_GetMissing_Bad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -109,8 +109,8 @@ func TestRegistry_Get_Bad_NotFound(t *testing.T) {
|
|||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestRegistry_Register_Good_CreatesDirectory(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons")
|
||||
func TestRegistry_CreateDirectory_Good(t *testing.T) {
|
||||
dir := core.JoinPath(t.TempDir(), "nested", "deep", "daemons")
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()})
|
||||
|
|
@ -121,7 +121,7 @@ func TestRegistry_Register_Good_CreatesDirectory(t *testing.T) {
|
|||
assert.True(t, info.IsDir())
|
||||
}
|
||||
|
||||
func TestDefaultRegistry_Good(t *testing.T) {
|
||||
func TestRegistry_Default_Good(t *testing.T) {
|
||||
reg := DefaultRegistry()
|
||||
assert.NotNil(t, reg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ func TestRunner_RunAll_Good(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestRunner_RunAll_Bad_CircularDeps(t *testing.T) {
|
||||
func TestRunner_CircularDeps_Bad(t *testing.T) {
|
||||
t.Run("circular dependency counts as failed", func(t *testing.T) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -78,7 +77,7 @@ func TestService_Start_Good(t *testing.T) {
|
|||
|
||||
<-proc.Done()
|
||||
|
||||
output := strings.TrimSpace(proc.Output())
|
||||
output := framework.Trim(proc.Output())
|
||||
assert.True(t, output == "/tmp" || output == "/private/tmp", "got: %s", output)
|
||||
})
|
||||
|
||||
|
|
@ -213,7 +212,7 @@ func TestService_Actions_Good(t *testing.T) {
|
|||
assert.NotEmpty(t, outputs)
|
||||
foundTest := false
|
||||
for _, o := range outputs {
|
||||
if strings.Contains(o.Line, "test") {
|
||||
if framework.Contains(o.Line, "test") {
|
||||
foundTest = true
|
||||
break
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue