import AppKit import Testing @testable import OpenClaw @Suite(.serialized) @MainActor struct MenuSessionsInjectorTests { @Test func `anchors dynamic rows below controls and actions`() throws { let injector = MenuSessionsInjector() let menu = NSMenu() menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: "")) menu.addItem(NSMenuItem(title: "Open Chat", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: "")) let footerSeparatorIndex = try #require(menu.items.lastIndex(where: { $0.isSeparatorItem })) #expect(injector.testingFindInsertIndex(in: menu) == footerSeparatorIndex) #expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex) } @Test func `injects disconnected message`() { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(false) injector.setTestingSnapshot(nil, errorText: nil) let menu = NSMenu() menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) injector.injectForTesting(into: menu) let contextItem = menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" } #expect(contextItem != nil) #expect(contextItem?.submenu != nil) } @Test func `injects session rows`() throws { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(true) let defaults = SessionDefaults(model: "anthropic/claude-opus-4-6", contextTokens: 200_000) let rows = [ SessionRow( id: "main", key: "main", kind: .direct, displayName: nil, provider: nil, subject: nil, room: nil, space: nil, updatedAt: Date(), sessionId: "s1", thinkingLevel: "low", verboseLevel: nil, systemSent: false, abortedLastRun: false, tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000), model: "claude-opus-4-6"), SessionRow( id: "discord:group:alpha", key: "discord:group:alpha", kind: .group, displayName: nil, provider: nil, subject: nil, room: nil, space: nil, updatedAt: Date(timeIntervalSinceNow: -60), sessionId: "s2", thinkingLevel: "high", verboseLevel: "debug", systemSent: true, abortedLastRun: true, tokens: SessionTokenStats(input: 50, output: 50, total: 100, contextTokens: 200_000), model: "claude-opus-4-6"), ] let snapshot = SessionStoreSnapshot( storePath: "/tmp/sessions.json", defaults: defaults, rows: rows) injector.setTestingSnapshot(snapshot, errorText: nil) let usage = GatewayUsageSummary( updatedAt: Date().timeIntervalSince1970 * 1000, providers: [ GatewayUsageProvider( provider: "anthropic", displayName: "Claude", windows: [GatewayUsageWindow(label: "5h", usedPercent: 12, resetAt: nil)], plan: "Pro", error: nil), GatewayUsageProvider( provider: "openai", displayName: "Codex", windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)], plan: nil, error: nil), ]) injector.setTestingUsageSummary(usage, errorText: nil) let menu = NSMenu() menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: "")) injector.injectForTesting(into: menu) let contextItem = try #require(menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" }) let contextSubmenu = try #require(contextItem.submenu) #expect(menu.items.count(where: { $0.tag == 9_415_557 && $0.title == "Context" }) == 1) #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) #expect(contextSubmenu.items.compactMap { $0.representedObject as? String }.count(where: { [ "main", "discord:group:alpha", ].contains($0) }) == 2) #expect(contextSubmenu.items.allSatisfy { $0.title != "Usage cost (30 days)" }) let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" })) let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" })) let firstInjectedIndex = try #require(menu.items.firstIndex(where: { $0.tag == 9_415_557 })) let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings…" })) #expect(sendHeartbeatsIndex < firstInjectedIndex) #expect(openDashboardIndex < firstInjectedIndex) #expect(firstInjectedIndex < settingsIndex) } @Test func `cost usage submenu does not use injector delegate`() { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(true) let summary = GatewayCostUsageSummary( updatedAt: Date().timeIntervalSince1970 * 1000, days: 1, daily: [ GatewayCostUsageDay( date: "2026-02-24", input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30, totalCost: 0.12, missingCostEntries: 0), ], totals: GatewayCostUsageTotals( input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30, totalCost: 0.12, missingCostEntries: 0)) injector.setTestingCostUsageSummary(summary, errorText: nil) let menu = NSMenu() menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) injector.injectForTesting(into: menu) let contextItem = menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" } #expect(contextItem?.submenu?.items.allSatisfy { $0.title != "Usage cost (30 days)" } == true) let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" } #expect(usageCostItem != nil) #expect(usageCostItem?.submenu != nil) #expect(usageCostItem?.submenu?.delegate == nil) } @Test func `status text keeps useful error detail`() { let injector = MenuSessionsInjector() let longError = """ Gateway connection dropped; gateway likely restarted. Reconnect after the gateway finishes booting. Details that should stay readable instead of collapsing into one tiny menu ellipsis. """ let normalized = injector.testingControlChannelStatusText(for: .degraded(longError)) #expect(normalized.contains("Gateway connection dropped")) #expect(normalized.contains("Reconnect after")) #expect(normalized.count <= 180) #expect(!normalized.contains("\n")) } @Test func `node status text distinguishes paired disconnected nodes`() { let pairedDisconnected = Self.node(id: "paired", paired: true, connected: false) let unpairedDisconnected = Self.node(id: "unpaired", paired: false, connected: false) let connected = Self.node(id: "connected", paired: true, connected: true) #expect(NodeMenuEntryFormatter.roleText(pairedDisconnected) == "paired · disconnected") #expect(NodeMenuEntryFormatter.roleText(unpairedDisconnected) == "unpaired · disconnected") #expect(NodeMenuEntryFormatter.roleText(connected) == "paired · connected") } @Test func `sorted node entries include paired disconnected nodes`() { let injector = MenuSessionsInjector() defer { NodesStore.shared.nodes = [] } NodesStore.shared.nodes = [ Self.node(id: "ignored", paired: false, connected: false, displayName: "Ignored"), Self.node(id: "paired", paired: true, connected: false, displayName: "MacBook"), Self.node(id: "connected", paired: true, connected: true, displayName: "iPhone"), ] let entries = injector.testingSortedNodeEntries() #expect(entries.map(\.nodeId) == ["connected", "paired"]) } private static func node( id: String, paired: Bool, connected: Bool, displayName: String? = nil) -> NodeInfo { NodeInfo( nodeId: id, displayName: displayName ?? id, platform: "macOS 26.3.1", version: nil, coreVersion: nil, uiVersion: nil, deviceFamily: "Mac", modelIdentifier: nil, remoteIp: nil, caps: nil, commands: nil, permissions: nil, paired: paired, connected: connected) } }