* feat(cli): add session commands * test(cli): cover session json output * chore(cli): trim unused session response fields
This commit is contained in:
parent
ae9fa0e341
commit
010b422bb5
|
|
@ -150,6 +150,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
|
|||
root.AddCommand(newSpawnCommand(ctx))
|
||||
root.AddCommand(newSendCommand(ctx))
|
||||
root.AddCommand(newProjectCommand(ctx))
|
||||
root.AddCommand(newSessionCommand(ctx))
|
||||
root.AddCommand(newCompletionCommand())
|
||||
root.AddCommand(newVersionCommand())
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,458 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type sessionOptions struct {
|
||||
project string
|
||||
json bool
|
||||
}
|
||||
|
||||
type sessionListOptions struct {
|
||||
sessionOptions
|
||||
all bool
|
||||
includeTerminated bool
|
||||
}
|
||||
|
||||
type sessionDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
IssueID string `json:"issueId,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Harness string `json:"harness,omitempty"`
|
||||
Activity sessionActivity `json:"activity"`
|
||||
IsTerminated bool `json:"isTerminated"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type sessionActivity struct {
|
||||
State string `json:"state"`
|
||||
LastActivityAt time.Time `json:"lastActivityAt"`
|
||||
}
|
||||
|
||||
type sessionListResponse struct {
|
||||
Sessions []sessionDTO `json:"sessions"`
|
||||
}
|
||||
|
||||
type sessionResponse struct {
|
||||
Session sessionDTO `json:"session"`
|
||||
}
|
||||
|
||||
type killSessionResponse struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
|
||||
type restoreSessionResponse struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Session sessionDTO `json:"session"`
|
||||
}
|
||||
|
||||
type sessionListEntry struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status,omitempty"`
|
||||
IssueID string `json:"issueId,omitempty"`
|
||||
Harness string `json:"harness,omitempty"`
|
||||
IsTerminated bool `json:"isTerminated"`
|
||||
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type sessionListOutput struct {
|
||||
Data []sessionListEntry `json:"data"`
|
||||
Meta struct {
|
||||
HiddenTerminatedCount int `json:"hiddenTerminatedCount"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
func newSessionCommand(ctx *commandContext) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "session",
|
||||
Short: "Manage agent sessions",
|
||||
}
|
||||
cmd.AddCommand(newSessionListCommand(ctx))
|
||||
cmd.AddCommand(newSessionGetCommand(ctx))
|
||||
cmd.AddCommand(newSessionKillCommand(ctx))
|
||||
cmd.AddCommand(newSessionRestoreCommand(ctx))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSessionListCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts sessionListOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Short: "List sessions",
|
||||
Args: noArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return ctx.listSessions(cmd.Context(), cmd, opts)
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
addSessionProjectFlag(f, &opts.project, "Filter by project ID")
|
||||
f.BoolVarP(&opts.all, "all", "a", false, "Include orchestrator sessions")
|
||||
f.BoolVar(&opts.includeTerminated, "include-terminated", false, "Include terminated sessions")
|
||||
f.BoolVar(&opts.json, "json", false, "Output as JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSessionGetCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts sessionOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Fetch one session",
|
||||
Args: oneSessionIDArg,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := normalizeSessionID(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.getSession(cmd.Context(), cmd, id, opts)
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
addSessionProjectFlag(f, &opts.project, "Project id to scope the lookup")
|
||||
f.BoolVar(&opts.json, "json", false, "Output as JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSessionKillCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts sessionOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "kill <id>",
|
||||
Short: "Terminate a session",
|
||||
Args: oneSessionIDArg,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := normalizeSessionID(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.killSession(cmd.Context(), cmd, id, opts)
|
||||
},
|
||||
}
|
||||
addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSessionRestoreCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts sessionOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore <id>",
|
||||
Short: "Relaunch a terminated session",
|
||||
Args: oneSessionIDArg,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := normalizeSessionID(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.restoreSession(cmd.Context(), cmd, id, opts)
|
||||
},
|
||||
}
|
||||
addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addSessionProjectFlag(flags interface {
|
||||
StringVarP(*string, string, string, string, string)
|
||||
}, target *string, usage string) {
|
||||
flags.StringVarP(target, "project", "p", "", usage)
|
||||
}
|
||||
|
||||
func oneSessionIDArg(cmd *cobra.Command, args []string) error {
|
||||
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
|
||||
return usageError{err}
|
||||
}
|
||||
if _, err := normalizeSessionID(args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commandContext) listSessions(ctx context.Context, cmd *cobra.Command, opts sessionListOptions) error {
|
||||
params := url.Values{}
|
||||
if opts.project != "" {
|
||||
params.Set("project", opts.project)
|
||||
}
|
||||
if !opts.includeTerminated {
|
||||
params.Set("active", "true")
|
||||
}
|
||||
var res sessionListResponse
|
||||
if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil {
|
||||
return err
|
||||
}
|
||||
sessions := filterAndSortSessions(res.Sessions, opts.all)
|
||||
hiddenTerminatedCount := 0
|
||||
if !opts.includeTerminated {
|
||||
count, err := c.countHiddenTerminated(ctx, opts.project, opts.all)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hiddenTerminatedCount = count
|
||||
}
|
||||
if opts.json {
|
||||
out := sessionListOutput{Data: sessionListEntries(sessions)}
|
||||
out.Meta.HiddenTerminatedCount = hiddenTerminatedCount
|
||||
return writeJSON(cmd.OutOrStdout(), out)
|
||||
}
|
||||
return writeSessionList(cmd, sessions, hiddenTerminatedCount)
|
||||
}
|
||||
|
||||
func (c *commandContext) countHiddenTerminated(ctx context.Context, project string, includeOrchestrators bool) (int, error) {
|
||||
params := url.Values{}
|
||||
if project != "" {
|
||||
params.Set("project", project)
|
||||
}
|
||||
params.Set("active", "false")
|
||||
var res sessionListResponse
|
||||
if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(filterAndSortSessions(res.Sessions, includeOrchestrators)), nil
|
||||
}
|
||||
|
||||
func (c *commandContext) getSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error {
|
||||
sess, err := c.fetchScopedSession(ctx, id, opts.project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.json {
|
||||
return writeJSON(cmd.OutOrStdout(), sessionResponse{Session: sess})
|
||||
}
|
||||
return writeSessionDetails(cmd, sess)
|
||||
}
|
||||
|
||||
func (c *commandContext) killSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error {
|
||||
if opts.project != "" {
|
||||
if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var res killSessionResponse
|
||||
if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/kill", struct{}{}, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "session %s killed\n", res.SessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *commandContext) restoreSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error {
|
||||
if opts.project != "" {
|
||||
if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var res restoreSessionResponse
|
||||
if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/restore", struct{}{}, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if _, err := fmt.Fprintf(out, "session %s restored\n", res.SessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Session.ProjectID != "" {
|
||||
if _, err := fmt.Fprintf(out, " project: %s\n", res.Session.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commandContext) fetchScopedSession(ctx context.Context, id, project string) (sessionDTO, error) {
|
||||
var res sessionResponse
|
||||
if err := c.getJSON(ctx, "sessions/"+url.PathEscape(id), &res); err != nil {
|
||||
return sessionDTO{}, err
|
||||
}
|
||||
if project != "" && res.Session.ProjectID != project {
|
||||
return sessionDTO{}, usageError{fmt.Errorf("session %s is not in project %s", id, project)}
|
||||
}
|
||||
return res.Session, nil
|
||||
}
|
||||
|
||||
func filterAndSortSessions(sessions []sessionDTO, includeOrchestrators bool) []sessionDTO {
|
||||
out := make([]sessionDTO, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
if !includeOrchestrators && sess.Kind == "orchestrator" {
|
||||
continue
|
||||
}
|
||||
out = append(out, sess)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].ProjectID != out[j].ProjectID {
|
||||
return out[i].ProjectID < out[j].ProjectID
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func sessionListEntries(sessions []sessionDTO) []sessionListEntry {
|
||||
entries := make([]sessionListEntry, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
var last *time.Time
|
||||
if !sess.Activity.LastActivityAt.IsZero() {
|
||||
activity := sess.Activity.LastActivityAt
|
||||
last = &activity
|
||||
}
|
||||
entries = append(entries, sessionListEntry{
|
||||
ID: sess.ID,
|
||||
ProjectID: sess.ProjectID,
|
||||
Role: sessionRole(sess),
|
||||
Status: sess.Status,
|
||||
IssueID: sess.IssueID,
|
||||
Harness: sess.Harness,
|
||||
IsTerminated: sess.IsTerminated,
|
||||
LastActivityAt: last,
|
||||
CreatedAt: sess.CreatedAt,
|
||||
UpdatedAt: sess.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func writeSessionList(cmd *cobra.Command, sessions []sessionDTO, hiddenTerminatedCount int) error {
|
||||
out := cmd.OutOrStdout()
|
||||
if len(sessions) == 0 {
|
||||
if _, err := fmt.Fprintln(out, "(no active sessions)"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
currentProject := ""
|
||||
for _, sess := range sessions {
|
||||
if sess.ProjectID != currentProject {
|
||||
if currentProject != "" {
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
currentProject = sess.ProjectID
|
||||
if _, err := fmt.Fprintf(out, "%s:\n", currentProject); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, " %s", sess.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
parts := sessionLineParts(sess)
|
||||
if len(parts) > 0 {
|
||||
if _, err := fmt.Fprintf(out, " %s", strings.Join(parts, " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if hiddenTerminatedCount > 0 {
|
||||
_, err := fmt.Fprintf(out, "%d terminated session%s hidden. Use --include-terminated to show.\n", hiddenTerminatedCount, pluralS(hiddenTerminatedCount))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sessionLineParts(sess sessionDTO) []string {
|
||||
parts := []string{}
|
||||
if !sess.Activity.LastActivityAt.IsZero() {
|
||||
parts = append(parts, "("+formatSessionAge(time.Since(sess.Activity.LastActivityAt))+")")
|
||||
}
|
||||
if sess.Status != "" {
|
||||
parts = append(parts, "["+sess.Status+"]")
|
||||
}
|
||||
if sess.Kind != "" {
|
||||
parts = append(parts, sess.Kind)
|
||||
}
|
||||
if sess.IssueID != "" {
|
||||
parts = append(parts, sess.IssueID)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func writeSessionDetails(cmd *cobra.Command, sess sessionDTO) error {
|
||||
out := cmd.OutOrStdout()
|
||||
fields := [][2]string{
|
||||
{"id", sess.ID},
|
||||
{"project", sess.ProjectID},
|
||||
{"role", sessionRole(sess)},
|
||||
{"status", sess.Status},
|
||||
{"activity", sess.Activity.State},
|
||||
{"harness", sess.Harness},
|
||||
{"issue", sess.IssueID},
|
||||
{"terminated", fmt.Sprintf("%t", sess.IsTerminated)},
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field[1] == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "%s: %s\n", field[0], field[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !sess.CreatedAt.IsZero() {
|
||||
if _, err := fmt.Fprintf(out, "created: %s\n", sess.CreatedAt.Format(time.RFC3339)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !sess.UpdatedAt.IsZero() {
|
||||
if _, err := fmt.Fprintf(out, "updated: %s\n", sess.UpdatedAt.Format(time.RFC3339)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sessionRole(sess sessionDTO) string {
|
||||
if sess.Kind == "orchestrator" {
|
||||
return "orchestrator"
|
||||
}
|
||||
return "worker"
|
||||
}
|
||||
|
||||
func formatSessionAge(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
|
||||
func pluralS(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func apiPath(path string, params url.Values) string {
|
||||
if len(params) == 0 {
|
||||
return path
|
||||
}
|
||||
return path + "?" + params.Encode()
|
||||
}
|
||||
|
||||
func normalizeSessionID(id string) (string, error) {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
return "", usageError{errors.New("session id is required")}
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type sessionRequestLog struct {
|
||||
mu sync.Mutex
|
||||
requests []string
|
||||
}
|
||||
|
||||
func (l *sessionRequestLog) append(r *http.Request) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
entry := r.Method + " " + r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
entry += "?" + r.URL.RawQuery
|
||||
}
|
||||
l.requests = append(l.requests, entry)
|
||||
}
|
||||
|
||||
func (l *sessionRequestLog) all() []string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]string(nil), l.requests...)
|
||||
}
|
||||
|
||||
func sessionCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) {
|
||||
t.Helper()
|
||||
log := &sessionRequestLog{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.append(r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions":
|
||||
active := r.URL.Query().Get("active")
|
||||
switch active {
|
||||
case "false":
|
||||
_, _ = io.WriteString(w, `{"sessions":[`+sessionJSON("demo-old", "demo", "worker", "terminated", true)+`]}`)
|
||||
default:
|
||||
_, _ = io.WriteString(w, `{"sessions":[`+
|
||||
sessionJSON("demo-2", "demo", "orchestrator", "idle", false)+`,`+
|
||||
sessionJSON("demo-1", "demo", "worker", "working", false)+`]}`)
|
||||
}
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1":
|
||||
_, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "working", false)+`}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/kill":
|
||||
_, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","freed":true}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/restore":
|
||||
_, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, log
|
||||
}
|
||||
|
||||
func sessionJSON(id, project, kind, status string, terminated bool) string {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"id": id,
|
||||
"projectId": project,
|
||||
"kind": kind,
|
||||
"harness": "codex",
|
||||
"activity": map[string]any{"state": "idle", "lastActivityAt": "2026-06-02T12:00:00Z"},
|
||||
"isTerminated": terminated,
|
||||
"createdAt": "2026-06-02T11:00:00Z",
|
||||
"updatedAt": "2026-06-02T12:00:00Z",
|
||||
"status": status,
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestSessionList_ProjectFilterAndDefaultFiltering(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
srv, log := sessionCommandServer(t)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
ProcessAlive: func(int) bool { return true },
|
||||
}, "session", "ls", "--project", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("session ls failed: %v\nstderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "demo:") || !strings.Contains(out, "demo-1") {
|
||||
t.Fatalf("output missing worker session:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "demo-2") {
|
||||
t.Fatalf("orchestrator session should be hidden without --all:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "1 terminated session hidden") {
|
||||
t.Fatalf("hidden terminated hint missing:\n%s", out)
|
||||
}
|
||||
want := []string{
|
||||
"GET /api/v1/sessions?active=true&project=demo",
|
||||
"GET /api/v1/sessions?active=false&project=demo",
|
||||
}
|
||||
if got := log.all(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("requests = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionList_JSONOutputDecodes(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
srv, _ := sessionCommandServer(t)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
ProcessAlive: func(int) bool { return true },
|
||||
}, "session", "ls", "--project", "demo", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("session ls --json failed: %v\nstderr=%s", err, errOut)
|
||||
}
|
||||
var got sessionListOutput
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("session ls --json output is not decodable: %v\noutput=%s", err, out)
|
||||
}
|
||||
if got.Meta.HiddenTerminatedCount != 1 {
|
||||
t.Fatalf("hiddenTerminatedCount = %d, want 1", got.Meta.HiddenTerminatedCount)
|
||||
}
|
||||
if len(got.Data) != 1 {
|
||||
t.Fatalf("len(data) = %d, want 1; data=%#v", len(got.Data), got.Data)
|
||||
}
|
||||
if got.Data[0].ID != "demo-1" || got.Data[0].ProjectID != "demo" || got.Data[0].Role != "worker" {
|
||||
t.Fatalf("unexpected JSON entry: %#v", got.Data[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionGet_SuccessWithProjectScope(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
srv, log := sessionCommandServer(t)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
ProcessAlive: func(int) bool { return true },
|
||||
}, "session", "get", "demo-1", "-p", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("session get failed: %v\nstderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "id: demo-1") || !strings.Contains(out, "project: demo") {
|
||||
t.Fatalf("unexpected get output:\n%s", out)
|
||||
}
|
||||
want := []string{"GET /api/v1/sessions/demo-1"}
|
||||
if got := log.all(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("requests = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionGet_JSONOutputDecodes(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
srv, _ := sessionCommandServer(t)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
ProcessAlive: func(int) bool { return true },
|
||||
}, "session", "get", "demo-1", "--project", "demo", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("session get --json failed: %v\nstderr=%s", err, errOut)
|
||||
}
|
||||
var got sessionResponse
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("session get --json output is not decodable: %v\noutput=%s", err, out)
|
||||
}
|
||||
if got.Session.ID != "demo-1" || got.Session.ProjectID != "demo" || got.Session.Status != "working" {
|
||||
t.Fatalf("unexpected session JSON: %#v", got.Session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionKill_SuccessWithProjectScope(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
srv, log := sessionCommandServer(t)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
ProcessAlive: func(int) bool { return true },
|
||||
}, "session", "kill", "demo-1", "--project", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("session kill failed: %v\nstderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "session demo-1 killed") {
|
||||
t.Fatalf("unexpected kill output:\n%s", out)
|
||||
}
|
||||
want := []string{"GET /api/v1/sessions/demo-1", "POST /api/v1/sessions/demo-1/kill"}
|
||||
if got := log.all(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("requests = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRestore_SuccessWithProjectScope(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
srv, log := sessionCommandServer(t)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
ProcessAlive: func(int) bool { return true },
|
||||
}, "session", "restore", "demo-1", "-p", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("session restore failed: %v\nstderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "session demo-1 restored") || !strings.Contains(out, "project: demo") {
|
||||
t.Fatalf("unexpected restore output:\n%s", out)
|
||||
}
|
||||
want := []string{"GET /api/v1/sessions/demo-1", "POST /api/v1/sessions/demo-1/restore"}
|
||||
if got := log.all(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("requests = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCommands_MissingIDIsUsageError(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
for _, sub := range []string{"get", "kill", "restore"} {
|
||||
t.Run(sub, func(t *testing.T) {
|
||||
_, _, err := executeCLI(t, Deps{}, "session", sub)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing id to fail")
|
||||
}
|
||||
if got := ExitCode(err); got != 2 {
|
||||
t.Fatalf("exit code = %d, want 2 (err=%v)", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionGet_ProjectMismatchDoesNotPassScope(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
srv, _ := sessionCommandServer(t)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{
|
||||
ProcessAlive: func(int) bool { return true },
|
||||
}, "session", "get", "demo-1", "--project", "other")
|
||||
if err == nil {
|
||||
t.Fatal("expected project mismatch to fail")
|
||||
}
|
||||
if got := ExitCode(err); got != 2 {
|
||||
t.Fatalf("exit code = %d, want 2", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not in project other") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue