440 lines
13 KiB
Go
440 lines
13 KiB
Go
package opsconsole
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
edgenode "iop/apps/edge/internal/node"
|
|
eventpkg "iop/packages/go/events"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
func TestEventRouterSuppressesTrackedForegroundRun(t *testing.T) {
|
|
var out bytes.Buffer
|
|
router := NewEventRouter(&out, nil, nil)
|
|
unregister := router.TrackForeground("run-1")
|
|
defer unregister()
|
|
|
|
router.Handle(&iop.RunEvent{RunId: "run-1", Type: "start"})
|
|
|
|
if out.Len() != 0 {
|
|
t.Fatalf("registered foreground event should not be printed asynchronously: %q", out.String())
|
|
}
|
|
}
|
|
|
|
func TestEventRouterIgnoresLateForegroundAfterUntrack(t *testing.T) {
|
|
var out bytes.Buffer
|
|
router := NewEventRouter(&out, nil, nil)
|
|
unregister := router.TrackForeground("run-fg")
|
|
unregister()
|
|
|
|
router.Handle(&iop.RunEvent{RunId: "run-fg", Type: "start", SessionId: "s1"})
|
|
router.Handle(&iop.RunEvent{RunId: "run-fg", Type: "delta", Delta: "hi"})
|
|
router.Handle(&iop.RunEvent{RunId: "run-fg", Type: "complete", Message: "done"})
|
|
|
|
if got := out.String(); got != "" {
|
|
t.Fatalf("expected no async output for late foreground events, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestEventRouterPrintsUnregisteredRun(t *testing.T) {
|
|
var out bytes.Buffer
|
|
router := NewEventRouter(&out, nil, nil)
|
|
|
|
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "start", SessionId: "s1", Background: true})
|
|
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "delta", Delta: "hello", Background: true})
|
|
if got := out.String(); !strings.Contains(got, "[unknown-msg] hello") {
|
|
t.Fatalf("expected delta to be printed before completion, got:\n%s", got)
|
|
}
|
|
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "delta", Delta: " world\n", Background: true})
|
|
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "complete", Message: "done", Background: true})
|
|
|
|
got := out.String()
|
|
for _, want := range []string{
|
|
"[unknown-evt] start run_id=run-bg session=s1 background=true",
|
|
"[unknown-evt] complete run_id=run-bg detail=\"done\"",
|
|
"[unknown-msg] hello world",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected output to contain %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResponseStreamWritesBeforeFinish(t *testing.T) {
|
|
var out bytes.Buffer
|
|
stream := NewResponseStream(&out, "[node0-msg] ")
|
|
|
|
stream.Write("hello")
|
|
if got := out.String(); got != "[node0-msg] hello" {
|
|
t.Fatalf("Write should print immediately, got %q", got)
|
|
}
|
|
|
|
stream.Write(" world")
|
|
stream.Finish()
|
|
if got := out.String(); got != "[node0-msg] hello world\n" {
|
|
t.Fatalf("Finish should close the line, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResponseStreamPrefixesEachMessageLine(t *testing.T) {
|
|
var out bytes.Buffer
|
|
stream := NewResponseStream(&out, "[node0-msg] ")
|
|
|
|
stream.Write("line one\nline two\nline three")
|
|
stream.Finish()
|
|
|
|
want := "[node0-msg] line one\n[node0-msg] line two\n[node0-msg] line three\n"
|
|
if got := out.String(); got != want {
|
|
t.Fatalf("unexpected multiline output:\nwant: %q\n got: %q", want, got)
|
|
}
|
|
}
|
|
|
|
func TestEventRouterPrintsNodeScopedAsyncRun(t *testing.T) {
|
|
var out bytes.Buffer
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"})
|
|
router := NewEventRouter(&out, reg, nil)
|
|
|
|
router.Handle(&iop.RunEvent{
|
|
RunId: "run-1",
|
|
Type: "start",
|
|
NodeId: "node-1",
|
|
Background: true,
|
|
})
|
|
|
|
got := out.String()
|
|
if !strings.Contains(got, "[node0-evt] start run_id=run-1") {
|
|
t.Errorf("expected node0 event label, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestEventRouterPrefersRunEventAlias(t *testing.T) {
|
|
var out bytes.Buffer
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "registry-alias"})
|
|
router := NewEventRouter(&out, reg, nil)
|
|
|
|
router.Handle(&iop.RunEvent{
|
|
RunId: "run-1",
|
|
Type: "start",
|
|
NodeId: "node-1",
|
|
NodeAlias: "event-alias",
|
|
Background: true,
|
|
})
|
|
|
|
got := out.String()
|
|
if !strings.Contains(got, "[node0-evt] start run_id=run-1") {
|
|
t.Errorf("expected node0 event label, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestEventRouterFallsBackToNodeID(t *testing.T) {
|
|
var out bytes.Buffer
|
|
router := NewEventRouter(&out, nil, nil)
|
|
|
|
router.Handle(&iop.RunEvent{
|
|
RunId: "run-1",
|
|
Type: "start",
|
|
NodeId: "some-raw-id",
|
|
Background: true,
|
|
})
|
|
|
|
got := out.String()
|
|
if !strings.Contains(got, "[some-raw-id-evt] start run_id=run-1") {
|
|
t.Errorf("expected raw id event label, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestEventRouterPrintsNodeLifecycleEvents(t *testing.T) {
|
|
var out bytes.Buffer
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"})
|
|
router := NewEventRouter(&out, reg, nil)
|
|
|
|
router.HandleNodeEvent(&iop.EdgeNodeEvent{
|
|
Type: eventpkg.TypeNodeDisconnected,
|
|
NodeId: "node-1",
|
|
Alias: "alias-1",
|
|
Reason: eventpkg.ReasonTransportClosed,
|
|
Metadata: map[string]string{
|
|
eventpkg.MetadataTransportCloseReason: "heartbeat_timeout",
|
|
eventpkg.MetadataTransportCloseError: "no heartbeat response within 10s",
|
|
},
|
|
})
|
|
|
|
got := out.String()
|
|
if !strings.Contains(got, `[node0-evt] disconnected reason="transport_closed"`) {
|
|
t.Errorf("expected disconnected lifecycle output, got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, `transport_close_reason="heartbeat_timeout"`) {
|
|
t.Errorf("expected transport close reason, got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, `transport_close_error="no heartbeat response within 10s"`) {
|
|
t.Errorf("expected transport close error, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestEventRouterKeepsCachedDisplayLabelForLateEvents(t *testing.T) {
|
|
var out bytes.Buffer
|
|
reg := edgenode.NewRegistry()
|
|
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"})
|
|
router := NewEventRouter(&out, reg, nil)
|
|
|
|
router.HandleNodeEvent(&iop.EdgeNodeEvent{
|
|
Type: eventpkg.TypeNodeConnected,
|
|
NodeId: "node-1",
|
|
Alias: "alias-1",
|
|
Reason: eventpkg.ReasonRegistered,
|
|
})
|
|
reg.Unregister("node-1")
|
|
|
|
router.Handle(&iop.RunEvent{
|
|
RunId: "run-late",
|
|
Type: "start",
|
|
NodeId: "node-1",
|
|
NodeAlias: "alias-1",
|
|
Background: true,
|
|
})
|
|
router.Handle(&iop.RunEvent{
|
|
RunId: "run-late",
|
|
Type: "delta",
|
|
Delta: "late payload",
|
|
NodeId: "node-1",
|
|
NodeAlias: "alias-1",
|
|
Background: true,
|
|
})
|
|
router.Handle(&iop.RunEvent{
|
|
RunId: "run-late",
|
|
Type: "complete",
|
|
Message: "done",
|
|
NodeId: "node-1",
|
|
NodeAlias: "alias-1",
|
|
Background: true,
|
|
})
|
|
|
|
got := out.String()
|
|
for _, want := range []string{
|
|
"[node0-evt] start run_id=run-late",
|
|
"[node0-msg] late payload",
|
|
"[node0-evt] complete run_id=run-late detail=\"done\"",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected cached label output %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
if strings.Contains(got, "[alias-1-") || strings.Contains(got, "[node-1-") {
|
|
t.Fatalf("late event leaked fallback label, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildNodeCommandRequest_StatusUsesCurrentTarget(t *testing.T) {
|
|
req, reqID := BuildNodeCommandRequest("cli", "codex", "default", 30)
|
|
if reqID == "" {
|
|
t.Fatal("expected non-empty reqID")
|
|
}
|
|
if req.GetType() != iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS {
|
|
t.Errorf("Type: got %v want %v", req.GetType(), iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS)
|
|
}
|
|
if req.GetAdapter() != "cli" {
|
|
t.Errorf("Adapter: got %q want %q", req.GetAdapter(), "cli")
|
|
}
|
|
if req.GetTarget() != "codex" {
|
|
t.Errorf("Target: got %q want %q", req.GetTarget(), "codex")
|
|
}
|
|
if req.GetSessionId() != "default" {
|
|
t.Errorf("SessionId: got %q want %q", req.GetSessionId(), "default")
|
|
}
|
|
if req.GetTimeoutSec() != 30 {
|
|
t.Errorf("TimeoutSec: got %d want 30", req.GetTimeoutSec())
|
|
}
|
|
}
|
|
|
|
func TestStatusWaitTimeout_UsesNormalizedRequestTimeout(t *testing.T) {
|
|
req, _ := BuildNodeCommandRequest("cli", "codex", "default", 0)
|
|
got := StatusWaitTimeout(req)
|
|
want := 35 * time.Second
|
|
if got != want {
|
|
t.Errorf("expected %v, got %v", want, got)
|
|
}
|
|
|
|
req2, _ := BuildNodeCommandRequest("cli", "codex", "default", 60)
|
|
got2 := StatusWaitTimeout(req2)
|
|
want2 := 65 * time.Second
|
|
if got2 != want2 {
|
|
t.Errorf("expected %v, got %v", want2, got2)
|
|
}
|
|
}
|
|
|
|
func TestFormatUsageStatus_PrintsParsedLimits(t *testing.T) {
|
|
var out bytes.Buffer
|
|
FormatUsageStatus(&out, "local-node", "codex", "default", &iop.AgentUsageStatus{
|
|
DailyLimit: "98%",
|
|
DailyResetTime: "18:38",
|
|
WeeklyLimit: "22%",
|
|
WeeklyResetTime: "10:20 on 8 May",
|
|
})
|
|
got := out.String()
|
|
if !strings.Contains(got, "Daily limit: 98% remaining (resets 18:38)") {
|
|
t.Errorf("expected Daily limit: 98%% remaining (resets 18:38), got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "Weekly limit: 22% remaining (resets 10:20 on 8 May") {
|
|
t.Errorf("expected Weekly limit: 22%% remaining (resets 10:20 on 8 May), got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatUsageStatus_PrintsClaudeLabels(t *testing.T) {
|
|
var out bytes.Buffer
|
|
FormatUsageStatus(&out, "local-node", "claude", "default", &iop.AgentUsageStatus{
|
|
DailyLimit: "98%",
|
|
DailyResetTime: "10am (Asia/Seoul)",
|
|
WeeklyLimit: "50%",
|
|
WeeklyResetTime: "May 16, 6pm (Asia/Seoul)",
|
|
Metadata: map[string]string{
|
|
"daily_label": "Current session",
|
|
"weekly_label": "Current week",
|
|
},
|
|
})
|
|
got := out.String()
|
|
if !strings.Contains(got, "Current session: 98% remaining (resets 10am (Asia/Seoul))") {
|
|
t.Errorf("expected Current session label, got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "Current week: 50% remaining (resets May 16, 6pm (Asia/Seoul))") {
|
|
t.Errorf("expected Current week label, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatUsageStatus_PrintsAntigravityQuotaMetadata(t *testing.T) {
|
|
var out bytes.Buffer
|
|
FormatUsageStatus(&out, "local-node", "antigravity", "default", &iop.AgentUsageStatus{
|
|
DailyLimit: "100%",
|
|
DailyResetTime: "24h",
|
|
Metadata: map[string]string{
|
|
"daily_label": "Daily quota",
|
|
"used_percent": "0%",
|
|
"usage_limit": "200",
|
|
},
|
|
})
|
|
got := out.String()
|
|
if !strings.Contains(got, "Daily quota: 100% remaining (resets 24h)") {
|
|
t.Errorf("expected Daily quota line, got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "Used: 0%") {
|
|
t.Errorf("expected Used: 0%%, got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "Usage limit: 200") {
|
|
t.Errorf("expected Usage limit: 200, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatUsageStatus_PrintsModelUsage(t *testing.T) {
|
|
var out bytes.Buffer
|
|
FormatUsageStatus(&out, "local-node", "antigravity", "default", &iop.AgentUsageStatus{
|
|
DailyLimit: "100%",
|
|
Metadata: map[string]string{
|
|
"daily_label": "Daily quota",
|
|
"used_percent": "0%",
|
|
"model_usage_count": "2",
|
|
"model_usage_0_name": "Flash",
|
|
"model_usage_0_used_percent": "0%",
|
|
"model_usage_0_reset": "2:15 PM (24h)",
|
|
"model_usage_1_name": "agy-3.1-flash-lite",
|
|
"model_usage_1_used_percent": "3%",
|
|
"model_usage_1_reset": "12:48 PM (22h 34m)",
|
|
},
|
|
})
|
|
got := out.String()
|
|
for _, want := range []string{
|
|
"Model usage:",
|
|
" Flash: 0% used (resets 2:15 PM (24h))",
|
|
" agy-3.1-flash-lite: 3% used (resets 12:48 PM (22h 34m))",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("expected %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFormatUsageStatus_PrintsClaudeStatusMetadata(t *testing.T) {
|
|
var out bytes.Buffer
|
|
FormatUsageStatus(&out, "local-node", "claude-tui", "default", &iop.AgentUsageStatus{
|
|
Metadata: map[string]string{
|
|
"status_kind": "claude",
|
|
"claude_status_version": "2.1.144",
|
|
"claude_status_login_method": "Claude Pro account",
|
|
"claude_status_model": "opus (claude-opus-4-7)",
|
|
"claude_status_cwd": "/config/workspace/iop",
|
|
"claude_status_session_id": "e26e764d",
|
|
"claude_status_email": "hidden@example.com",
|
|
"claude_status_organization": "hidden org",
|
|
"claude_status_session_name": "/rename to add a name",
|
|
"claude_status_mcp_servers": "3 need auth",
|
|
},
|
|
})
|
|
got := out.String()
|
|
for _, want := range []string{
|
|
"Claude status:",
|
|
" version = 2.1.144",
|
|
" login_method = Claude Pro account",
|
|
" model = opus (claude-opus-4-7)",
|
|
" cwd = /config/workspace/iop",
|
|
" session_id = e26e764d",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("expected %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
if strings.Contains(got, "hidden@example.com") || strings.Contains(got, "hidden org") {
|
|
t.Errorf("expected private Claude fields to stay hidden, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatUsageStatus_PrintsFallbackMetadata(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status *iop.AgentUsageStatus
|
|
wantContain string
|
|
}{
|
|
{
|
|
"raw_only",
|
|
&iop.AgentUsageStatus{
|
|
RawOutput: "some unparsed raw text",
|
|
Metadata: map[string]string{"parse_status": "raw_only"},
|
|
},
|
|
"parse_status=raw_only",
|
|
},
|
|
{
|
|
"empty",
|
|
&iop.AgentUsageStatus{
|
|
Metadata: map[string]string{"parse_status": "empty"},
|
|
},
|
|
"parse_status=empty",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var out bytes.Buffer
|
|
FormatUsageStatus(&out, "local-node", "codex", "default", tc.status)
|
|
got := out.String()
|
|
if !strings.Contains(got, tc.wantContain) {
|
|
t.Errorf("expected %q in output, got:\n%s", tc.wantContain, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatUsageStatus_Error(t *testing.T) {
|
|
var out bytes.Buffer
|
|
FormatUsageStatus(&out, "local-node", "codex", "default", &iop.AgentUsageStatus{
|
|
RawOutput: "unparsed error text",
|
|
})
|
|
got := out.String()
|
|
if !strings.Contains(got, "raw output did not include parsed limits:") {
|
|
t.Errorf("expected raw output warning, got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "unparsed error text") {
|
|
t.Errorf("expected unparsed text, got:\n%s", got)
|
|
}
|
|
}
|