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 TestEventRouterPrintsReasoningDeltaForAsyncRun(t *testing.T) { var out bytes.Buffer reg := edgenode.NewRegistry() reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "node1"}) router := NewEventRouter(&out, reg, nil) router.Handle(&iop.RunEvent{ RunId: "run-rd", Type: "reasoning_delta", Delta: "사용자가 인사했습니다.\n\x1f\x1f\n", NodeId: "node-1", Background: true, }) got := out.String() want1 := "[node0-reasoning] 사용자가 인사했습니다." want2 := "[node0-reasoning] \x1f\x1f" if !strings.Contains(got, want1) { t.Errorf("expected %q in output, got:\n%s", want1, got) } if !strings.Contains(got, want2) { t.Errorf("expected %q in output, got:\n%s", want2, got) } if strings.Contains(got, "reasoning_delta run_id=") || strings.Contains(got, `detail=""`) { t.Errorf("reasoning delta should not fall through to detail, got:\n%s", got) } if strings.Contains(got, "[node0-msg]") { t.Errorf("reasoning delta should not appear as msg content, 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", "cli@local", "codex", "default", &iop.AgentUsageStatus{ DailyLimit: "98%", DailyResetTime: "18:38", WeeklyLimit: "22%", WeeklyResetTime: "10:20 on 8 May", }) got := out.String() if !strings.Contains(got, "adapter=cli@local target=codex session=default") { t.Errorf("expected header with adapter identity, got:\n%s", got) } 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", "cli@local", "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", "cli@local", "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", "cli@local", "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", "cli@1", "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", "cli@local", "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", "cli@local", "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) } }