feat: add tmux runtime adapter
This commit is contained in:
parent
cdfd97cb98
commit
f975ca24c9
|
|
@ -0,0 +1,89 @@
|
|||
package tmux
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const runtimeName = "tmux"
|
||||
|
||||
func newSessionArgs(id, workspacePath, shellPath, script string) []string {
|
||||
return []string{"new-session", "-d", "-s", id, "-c", workspacePath, shellPath, "-lc", script}
|
||||
}
|
||||
|
||||
func setStatusOffArgs(id string) []string {
|
||||
return []string{"set-option", "-t", id, "status", "off"}
|
||||
}
|
||||
|
||||
func hasSessionArgs(id string) []string {
|
||||
return []string{"has-session", "-t", id}
|
||||
}
|
||||
|
||||
func killSessionArgs(id string) []string {
|
||||
return []string{"kill-session", "-t", id}
|
||||
}
|
||||
|
||||
func capturePaneArgs(id string, lines int) []string {
|
||||
return []string{"capture-pane", "-p", "-t", id, "-S", fmt.Sprintf("-%d", lines)}
|
||||
}
|
||||
|
||||
func sendLiteralArgs(id, message string) []string {
|
||||
return []string{"send-keys", "-t", id, "-l", message}
|
||||
}
|
||||
|
||||
func sendEnterArgs(id string) []string {
|
||||
return []string{"send-keys", "-t", id, "C-m"}
|
||||
}
|
||||
|
||||
func loadBufferArgs(bufferName, path string) []string {
|
||||
return []string{"load-buffer", "-b", bufferName, path}
|
||||
}
|
||||
|
||||
func pasteBufferArgs(id, bufferName string) []string {
|
||||
return []string{"paste-buffer", "-d", "-t", id, "-b", bufferName}
|
||||
}
|
||||
|
||||
func wrapLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string {
|
||||
path := cfg.Env["PATH"]
|
||||
if path == "" {
|
||||
path = getenv("PATH")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, key := range sortedKeys(cfg.Env) {
|
||||
if key == "PATH" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("export ")
|
||||
b.WriteString(key)
|
||||
b.WriteString("=")
|
||||
b.WriteString(shellQuote(cfg.Env[key]))
|
||||
b.WriteString("; ")
|
||||
}
|
||||
if path != "" {
|
||||
b.WriteString("export PATH=")
|
||||
b.WriteString(shellQuote(path))
|
||||
b.WriteString("; ")
|
||||
}
|
||||
b.WriteString(cfg.LaunchCommand)
|
||||
b.WriteString("; exec ")
|
||||
b.WriteString(shellQuote(shellPath))
|
||||
b.WriteString(" -i")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
// Package tmux implements ports.Runtime using tmux sessions.
|
||||
package tmux
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const defaultTimeout = 5 * time.Second
|
||||
const longMessageThreshold = 512
|
||||
|
||||
var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
|
||||
var getenv = os.Getenv
|
||||
|
||||
type Options struct {
|
||||
Binary string
|
||||
Timeout time.Duration
|
||||
Shell string
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
binary string
|
||||
timeout time.Duration
|
||||
shell string
|
||||
runner runner
|
||||
}
|
||||
|
||||
var _ ports.Runtime = (*Runtime)(nil)
|
||||
|
||||
type runner interface {
|
||||
Run(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
type execRunner struct{}
|
||||
|
||||
func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
return exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||
}
|
||||
|
||||
func New(opts Options) *Runtime {
|
||||
binary := opts.Binary
|
||||
if binary == "" {
|
||||
binary = "tmux"
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
shellPath := opts.Shell
|
||||
if shellPath == "" {
|
||||
shellPath = os.Getenv("SHELL")
|
||||
}
|
||||
if shellPath == "" {
|
||||
shellPath = "/bin/zsh"
|
||||
}
|
||||
return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}}
|
||||
}
|
||||
|
||||
func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
|
||||
id := string(cfg.SessionID)
|
||||
if err := validateSessionID(id); err != nil {
|
||||
return ports.RuntimeHandle{}, err
|
||||
}
|
||||
if cfg.WorkspacePath == "" {
|
||||
return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required")
|
||||
}
|
||||
if cfg.LaunchCommand == "" {
|
||||
return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required")
|
||||
}
|
||||
|
||||
script := wrapLaunchCommand(cfg, r.shell)
|
||||
if _, err := r.run(ctx, newSessionArgs(id, cfg.WorkspacePath, r.shell, script)...); err != nil {
|
||||
return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err)
|
||||
}
|
||||
if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil {
|
||||
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
|
||||
return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: disable status %s: %w", id, err)
|
||||
}
|
||||
return ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error {
|
||||
id, err := handleID(handle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alive, err := r.IsAlive(ctx, handle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !alive {
|
||||
return nil
|
||||
}
|
||||
if _, err := r.run(ctx, killSessionArgs(id)...); err != nil {
|
||||
return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error {
|
||||
id, err := handleID(handle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if useBuffer(message) {
|
||||
return r.sendViaBuffer(ctx, id, message)
|
||||
}
|
||||
if _, err := r.run(ctx, sendLiteralArgs(id, message)...); err != nil {
|
||||
return fmt.Errorf("tmux runtime: send message %s: %w", id, err)
|
||||
}
|
||||
if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil {
|
||||
return fmt.Errorf("tmux runtime: send enter %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
|
||||
id, err := handleID(handle)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if lines <= 0 {
|
||||
return "", errors.New("tmux runtime: lines must be positive")
|
||||
}
|
||||
out, err := r.run(ctx, capturePaneArgs(id, lines)...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) {
|
||||
id, err := handleID(handle)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = r.run(ctx, hasSessionArgs(id)...)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err)
|
||||
}
|
||||
|
||||
func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) {
|
||||
id, err := handleID(handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]string{r.binary}, "attach", "-t", id), nil
|
||||
}
|
||||
|
||||
func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error {
|
||||
dir := os.TempDir()
|
||||
file, err := os.CreateTemp(dir, "ao-tmux-message-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tmux runtime: create message temp file: %w", err)
|
||||
}
|
||||
path := file.Name()
|
||||
defer os.Remove(path)
|
||||
if _, err := file.WriteString(message); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("tmux runtime: write message temp file: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("tmux runtime: close message temp file: %w", err)
|
||||
}
|
||||
|
||||
bufferName := "ao-" + filepath.Base(path)
|
||||
if _, err := r.run(ctx, loadBufferArgs(bufferName, path)...); err != nil {
|
||||
return fmt.Errorf("tmux runtime: load buffer %s: %w", id, err)
|
||||
}
|
||||
if _, err := r.run(ctx, pasteBufferArgs(id, bufferName)...); err != nil {
|
||||
return fmt.Errorf("tmux runtime: paste buffer %s: %w", id, err)
|
||||
}
|
||||
if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil {
|
||||
return fmt.Errorf("tmux runtime: send enter %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||
defer cancel()
|
||||
out, err := r.runner.Run(cmdCtx, r.binary, args...)
|
||||
if cmdCtx.Err() != nil {
|
||||
return out, cmdCtx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return out, commandError{err: err, output: strings.TrimSpace(string(out))}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateSessionID(id string) error {
|
||||
if id == "" {
|
||||
return errors.New("tmux runtime: session id is required")
|
||||
}
|
||||
if !sessionIDPattern.MatchString(id) {
|
||||
return fmt.Errorf("tmux runtime: invalid session id %q", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleID(handle ports.RuntimeHandle) (string, error) {
|
||||
if handle.RuntimeName != "" && handle.RuntimeName != runtimeName {
|
||||
return "", fmt.Errorf("tmux runtime: wrong runtime %q", handle.RuntimeName)
|
||||
}
|
||||
if err := validateSessionID(handle.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return handle.ID, nil
|
||||
}
|
||||
|
||||
func useBuffer(message string) bool {
|
||||
return strings.Contains(message, "\n") || len(message) > longMessageThreshold
|
||||
}
|
||||
|
||||
type commandError struct {
|
||||
err error
|
||||
output string
|
||||
}
|
||||
|
||||
func (e commandError) Error() string {
|
||||
if e.output == "" {
|
||||
return e.err.Error()
|
||||
}
|
||||
return e.err.Error() + ": " + e.output
|
||||
}
|
||||
|
||||
func (e commandError) Unwrap() error { return e.err }
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package tmux
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestRuntimeIntegration(t *testing.T) {
|
||||
if _, err := exec.LookPath("tmux"); err != nil {
|
||||
t.Skip("tmux unavailable")
|
||||
}
|
||||
|
||||
r := New(Options{Timeout: 5 * time.Second})
|
||||
ctx := context.Background()
|
||||
id := "ao_itest_tmux"
|
||||
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
|
||||
|
||||
h, err := r.Create(ctx, ports.RuntimeConfig{
|
||||
SessionID: "ao_itest_tmux",
|
||||
WorkspacePath: t.TempDir(),
|
||||
LaunchCommand: "printf ready\\n",
|
||||
Env: map[string]string{"AO_SESSION_ID": id},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
defer r.Destroy(ctx, h)
|
||||
|
||||
alive, err := r.IsAlive(ctx, h)
|
||||
if err != nil {
|
||||
t.Fatalf("IsAlive: %v", err)
|
||||
}
|
||||
if !alive {
|
||||
t.Fatal("alive = false, want true")
|
||||
}
|
||||
|
||||
if err := r.SendMessage(ctx, h, "printf hello-from-tmux"); err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var out string
|
||||
for time.Now().Before(deadline) {
|
||||
out, err = r.GetOutput(ctx, h, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOutput: %v", err)
|
||||
}
|
||||
if strings.Contains(out, "hello-from-tmux") {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(out, "hello-from-tmux") {
|
||||
t.Fatalf("output = %q, want sent command output", out)
|
||||
}
|
||||
|
||||
if err := r.Destroy(ctx, h); err != nil {
|
||||
t.Fatalf("Destroy: %v", err)
|
||||
}
|
||||
alive, err = r.IsAlive(ctx, h)
|
||||
if err != nil {
|
||||
t.Fatalf("IsAlive after destroy: %v", err)
|
||||
}
|
||||
if alive {
|
||||
t.Fatal("alive after destroy = true, want false")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
package tmux
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestCommandBuilders(t *testing.T) {
|
||||
if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/zsh", "echo hi"), []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws", "/bin/zsh", "-lc", "echo hi"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("newSessionArgs = %#v, want %#v", got, want)
|
||||
}
|
||||
if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want)
|
||||
}
|
||||
if got, want := capturePaneArgs("sess-1", 42), []string{"capture-pane", "-p", "-t", "sess-1", "-S", "-42"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("capturePaneArgs = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSessionID(t *testing.T) {
|
||||
valid := []string{"sess-1", "S_2", "abc123"}
|
||||
for _, id := range valid {
|
||||
if err := validateSessionID(id); err != nil {
|
||||
t.Fatalf("validateSessionID(%q): %v", id, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{"", "sess.1", "sess/1", "$(boom)", "with space"}
|
||||
for _, id := range invalid {
|
||||
if err := validateSessionID(id); err == nil {
|
||||
t.Fatalf("validateSessionID(%q): got nil, want error", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapLaunchCommandExportsEnvAndKeepsPaneAlive(t *testing.T) {
|
||||
oldGetenv := getenv
|
||||
getenv = func(key string) string {
|
||||
if key == "PATH" {
|
||||
return "/usr/bin:/bin"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
defer func() { getenv = oldGetenv }()
|
||||
|
||||
got := wrapLaunchCommand(ports.RuntimeConfig{LaunchCommand: "ao run", Env: map[string]string{
|
||||
"AO_SESSION_ID": "sess-1",
|
||||
"ODD": "can't",
|
||||
"PATH": "/custom/bin:/usr/bin",
|
||||
}}, "/bin/zsh")
|
||||
|
||||
for _, want := range []string{
|
||||
"export AO_SESSION_ID='sess-1';",
|
||||
"export ODD='can'\\''t';",
|
||||
"export PATH='/custom/bin:/usr/bin';",
|
||||
"ao run; exec '/bin/zsh' -i",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("wrapped command missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRunsNewSessionAndDisablesStatus(t *testing.T) {
|
||||
fr := &fakeRunner{}
|
||||
r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/zsh"})
|
||||
r.runner = fr
|
||||
|
||||
handle, err := r.Create(context.Background(), ports.RuntimeConfig{
|
||||
SessionID: "sess-1",
|
||||
WorkspacePath: "/tmp/ws",
|
||||
LaunchCommand: "echo ready",
|
||||
Env: map[string]string{"AO_SESSION_ID": "sess-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if handle != (ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}) {
|
||||
t.Fatalf("handle = %+v, want tmux handle", handle)
|
||||
}
|
||||
if len(fr.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want 2", len(fr.calls))
|
||||
}
|
||||
if got, want := fr.calls[0].args[:6], []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("create args prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("status args = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageUsesLiteralForShortInput(t *testing.T) {
|
||||
fr := &fakeRunner{}
|
||||
r := New(Options{Timeout: time.Second})
|
||||
r.runner = fr
|
||||
|
||||
if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello"); err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
if got, want := fr.calls[0].args, sendLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("literal args = %#v, want %#v", got, want)
|
||||
}
|
||||
if got, want := fr.calls[1].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("enter args = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageUsesBufferForMultilineInput(t *testing.T) {
|
||||
fr := &fakeRunner{}
|
||||
r := New(Options{Timeout: time.Second})
|
||||
r.runner = fr
|
||||
|
||||
if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello\nworld"); err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
if len(fr.calls) != 3 {
|
||||
t.Fatalf("calls = %d, want 3", len(fr.calls))
|
||||
}
|
||||
if fr.calls[0].args[0] != "load-buffer" {
|
||||
t.Fatalf("first command = %#v, want load-buffer", fr.calls[0].args)
|
||||
}
|
||||
if got := fr.calls[1].args; !reflect.DeepEqual(got[:4], []string{"paste-buffer", "-d", "-t", "sess-1"}) {
|
||||
t.Fatalf("paste args = %#v", got)
|
||||
}
|
||||
if got, want := fr.calls[2].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("enter args = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAliveTreatsExitStatusAsNotAlive(t *testing.T) {
|
||||
fr := &fakeRunner{err: &exec.ExitError{}}
|
||||
r := New(Options{Timeout: time.Second})
|
||||
r.runner = fr
|
||||
|
||||
alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName})
|
||||
if err != nil {
|
||||
t.Fatalf("IsAlive: %v", err)
|
||||
}
|
||||
if alive {
|
||||
t.Fatal("alive = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) {
|
||||
fr := &fakeRunner{err: &exec.ExitError{}}
|
||||
r := New(Options{Timeout: time.Second})
|
||||
r.runner = fr
|
||||
|
||||
if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}); err != nil {
|
||||
t.Fatalf("Destroy: %v", err)
|
||||
}
|
||||
if len(fr.calls) != 1 || fr.calls[0].args[0] != "has-session" {
|
||||
t.Fatalf("calls = %#v, want only has-session", fr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOutputValidatesLines(t *testing.T) {
|
||||
r := New(Options{Timeout: time.Second})
|
||||
_, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, 0)
|
||||
if err == nil {
|
||||
t.Fatal("GetOutput lines=0: got nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRunner struct {
|
||||
calls []runnerCall
|
||||
out []byte
|
||||
err error
|
||||
}
|
||||
|
||||
type runnerCall struct {
|
||||
name string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
f.calls = append(f.calls, runnerCall{name: name, args: append([]string(nil), args...)})
|
||||
if f.err != nil {
|
||||
return f.out, f.err
|
||||
}
|
||||
return f.out, nil
|
||||
}
|
||||
|
||||
func TestCommandErrorUnwraps(t *testing.T) {
|
||||
base := errors.New("base")
|
||||
err := commandError{err: base, output: "details"}
|
||||
if !errors.Is(err, base) {
|
||||
t.Fatal("commandError should unwrap base error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "details") {
|
||||
t.Fatalf("error = %q, want output details", err.Error())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue