Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions Tests/StackNudgePanelCoreTests/EventListenerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import XCTest

@testable import StackNudgePanelCore

// Pure-logic tests for the wire-format parser. The socket I/O itself is
// exercised by the running app; what regresses silently is the
// newline-split + DTO decode pipeline. A malformed line should not take
// down the whole batch; missing optional fields should default cleanly.
final class EventListenerTests: XCTestCase {

private func payload(_ json: String) -> Data {
Data(json.utf8)
}

// MARK: - Single events

func test_parseEvents_decodesMinimalEvent() {
let data = payload(#"""
{"agent":"claude-code","event":"stop","title":"Done","message":"All set"}
"""#)
let events = EventListener.parseEvents(data)
XCTAssertEqual(events.count, 1)
XCTAssertEqual(events[0].agent, "claude-code")
XCTAssertEqual(events[0].kind, .stop)
XCTAssertEqual(events[0].title, "Done")
XCTAssertEqual(events[0].message, "All set")
}

func test_parseEvents_unknownKindFallsBackToOther() {
let data = payload(#"""
{"agent":"claude-code","event":"future_kind","title":"x","message":"y"}
"""#)
let events = EventListener.parseEvents(data)
XCTAssertEqual(events.count, 1)
XCTAssertEqual(events[0].kind, .other)
}

func test_parseEvents_populatesOptionalEnrichmentFields() {
// Must stay on a single line — parseEvents splits on \n before
// decoding, so any embedded newlines in the JSON payload would
// shred it into malformed fragments and the test would observe
// zero events.
let data = payload(#"{"agent":"claude-code","event":"permission","title":"Allow?","message":"rm -rf","project_path":"/repo","bundle_id":"com.googlecode.iterm2","window_title":"agent: repo","session_id":"w0t0p0","term_program":"iTerm.app","claude_session_id":"abc-123","transcript_path":"/tmp/t.jsonl","sound_name":"Ping","voice_message":"Please review","bypass_mute":true,"has_action_button":true}"#)
let events = EventListener.parseEvents(data)
XCTAssertEqual(events.count, 1)
let e = events[0]
XCTAssertEqual(e.projectPath, "/repo")
XCTAssertEqual(e.bundleID, "com.googlecode.iterm2")
XCTAssertEqual(e.windowTitle, "agent: repo")
XCTAssertEqual(e.sessionID, "w0t0p0")
XCTAssertEqual(e.termProgram, "iTerm.app")
XCTAssertEqual(e.claudeSessionID, "abc-123")
XCTAssertEqual(e.transcriptPath, "/tmp/t.jsonl")
XCTAssertEqual(e.soundName, "Ping")
XCTAssertEqual(e.voiceMessage, "Please review")
XCTAssertTrue(e.bypassMute)
XCTAssertTrue(e.hasActionButton)
}

func test_parseEvents_missingTimestampDefaultsToNow() {
let before = Date()
let data = payload(#"""
{"agent":"a","event":"stop","title":"t","message":"m"}
"""#)
let events = EventListener.parseEvents(data)
let after = Date()
XCTAssertEqual(events.count, 1)
XCTAssertGreaterThanOrEqual(events[0].timestamp, before)
XCTAssertLessThanOrEqual(events[0].timestamp, after)
}

func test_parseEvents_explicitTimestampPreserved() {
let data = payload(#"""
{"agent":"a","event":"stop","title":"t","message":"m","timestamp":1700000000}
"""#)
let events = EventListener.parseEvents(data)
XCTAssertEqual(events.count, 1)
XCTAssertEqual(events[0].timestamp.timeIntervalSince1970, 1700000000, accuracy: 0.001)
}

// MARK: - Batching + recovery

func test_parseEvents_handlesMultipleLines() {
let line1 = #"{"agent":"a","event":"stop","title":"t1","message":"m1"}"#
let line2 = #"{"agent":"b","event":"permission","title":"t2","message":"m2"}"#
let data = payload("\(line1)\n\(line2)\n")
let events = EventListener.parseEvents(data)
XCTAssertEqual(events.count, 2)
XCTAssertEqual(events[0].title, "t1")
XCTAssertEqual(events[1].title, "t2")
}

func test_parseEvents_keepsTrailingLineWithoutNewline() {
let line1 = #"{"agent":"a","event":"stop","title":"first","message":"m"}"#
let line2 = #"{"agent":"b","event":"stop","title":"second","message":"m"}"#
// Deliberately no trailing newline on the last line — notify.sh
// doesn't always terminate.
let data = payload("\(line1)\n\(line2)")
let events = EventListener.parseEvents(data)
XCTAssertEqual(events.count, 2)
XCTAssertEqual(events[1].title, "second")
}

func test_parseEvents_dropsMalformedLineButKeepsRest() {
let good1 = #"{"agent":"a","event":"stop","title":"good1","message":"m"}"#
let bad = #"{not valid json"#
let good2 = #"{"agent":"a","event":"stop","title":"good2","message":"m"}"#
let data = payload("\(good1)\n\(bad)\n\(good2)\n")
let events = EventListener.parseEvents(data)
XCTAssertEqual(events.count, 2)
XCTAssertEqual(events.map(\.title), ["good1", "good2"])
}

func test_parseEvents_returnsEmptyOnAllBlank() {
XCTAssertEqual(EventListener.parseEvents(Data()).count, 0)
XCTAssertEqual(EventListener.parseEvents(payload("\n\n\n")).count, 0)
}
}
101 changes: 101 additions & 0 deletions Tests/StackNudgePanelCoreTests/UpdateCheckerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import XCTest

@testable import StackNudgePanelCore

// Pure-logic tests for the version-comparison and release-asset matching
// helpers. UpdateChecker's network paths are exercised manually; the
// regressable pieces are these static helpers, which gate when the
// "Update available" badge appears and which artifact we try to install.
final class UpdateCheckerTests: XCTestCase {

// MARK: - stripV

func test_stripV_removesLeadingV() {
XCTAssertEqual(UpdateChecker.stripV("v1.2.3"), "1.2.3")
}

func test_stripV_isNoOpWithoutPrefix() {
XCTAssertEqual(UpdateChecker.stripV("1.2.3"), "1.2.3")
}

func test_stripV_doesNotStripMidString() {
XCTAssertEqual(UpdateChecker.stripV("1.v2.3"), "1.v2.3")
}

// MARK: - isNewer

func test_isNewer_returnsTrueOnHigherPatch() {
XCTAssertTrue(UpdateChecker.isNewer("1.2.4", than: "1.2.3"))
}

func test_isNewer_returnsFalseOnEqual() {
XCTAssertFalse(UpdateChecker.isNewer("1.2.3", than: "1.2.3"))
}

func test_isNewer_returnsFalseOnLower() {
XCTAssertFalse(UpdateChecker.isNewer("1.2.2", than: "1.2.3"))
}

// The classic semver pitfall: "1.10" must beat "1.9" numerically,
// not lexicographically. A regression here means users on 1.9 never
// see the 1.10 update.
func test_isNewer_comparesComponentsNumericallyNotLexically() {
XCTAssertTrue(UpdateChecker.isNewer("1.10.0", than: "1.9.0"))
XCTAssertTrue(UpdateChecker.isNewer("0.20.0", than: "0.3.0"))
}

func test_isNewer_handlesAsymmetricComponentCounts() {
// "2.0" vs "2.0.0" should treat as equal (missing trailing zero).
XCTAssertFalse(UpdateChecker.isNewer("2.0", than: "2.0.0"))
XCTAssertFalse(UpdateChecker.isNewer("2.0.0", than: "2.0"))
XCTAssertTrue(UpdateChecker.isNewer("2.0.1", than: "2.0"))
}

func test_isNewer_handlesMinorBump() {
XCTAssertTrue(UpdateChecker.isNewer("1.16.0", than: "1.15.5"))
}

// MARK: - hasArtifact

private func release(assetNames: [String]) -> [String: Any] {
["assets": assetNames.map { ["name": $0] as [String: Any] }]
}

func test_hasArtifact_findsMatchingArm64() {
let json = release(assetNames: [
"stack-nudge-1.16.1-macos-arm64.tar.gz",
"stack-nudge-1.16.1-macos-arm64.tar.gz.sha256",
"stack-nudge-1.16.1-macos-x86_64.tar.gz",
])
XCTAssertTrue(UpdateChecker.hasArtifact(in: json, arch: "arm64"))
XCTAssertTrue(UpdateChecker.hasArtifact(in: json, arch: "x86_64"))
}

// The pre-CI-build window: release-please created the tag but the
// build/upload step hasn't finished. Asset list is empty (or only has
// unrelated files). Badge must stay hidden.
func test_hasArtifact_returnsFalseWhenAssetsEmpty() {
XCTAssertFalse(UpdateChecker.hasArtifact(in: ["assets": [[String: Any]]()], arch: "arm64"))
}

func test_hasArtifact_returnsFalseWhenAssetsMissing() {
XCTAssertFalse(UpdateChecker.hasArtifact(in: [:], arch: "arm64"))
XCTAssertFalse(UpdateChecker.hasArtifact(in: nil, arch: "arm64"))
}

// sha256 sidecar must not be mistaken for the tarball — they share
// the arch suffix as a substring.
func test_hasArtifact_ignoresShaSidecarOnly() {
let json = release(assetNames: [
"stack-nudge-1.16.1-macos-arm64.tar.gz.sha256",
])
XCTAssertFalse(UpdateChecker.hasArtifact(in: json, arch: "arm64"))
}

func test_hasArtifact_returnsFalseWhenOnlyOtherArchPresent() {
let json = release(assetNames: [
"stack-nudge-1.16.1-macos-arm64.tar.gz",
])
XCTAssertFalse(UpdateChecker.hasArtifact(in: json, arch: "x86_64"))
}
}
6 changes: 6 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ sign_venv_contents() {

echo "Building stack-nudge ($ARCH)..."
rm -rf build
mkdir -p build
# Exclude the dev build from Spotlight so re-builds don't surface a stale
# StackNudge.app duplicate next to ~/Applications/StackNudge.app in search
# results. Touched here (not committed) because `rm -rf build` wipes it
# every run.
touch build/.metadata_never_index

build_app "$APP" "stack-nudge" \
"panel/Info.plist" "notifier/Icon.icns" "13.0" \
Expand Down
68 changes: 68 additions & 0 deletions panel/CompactView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ struct CompactView: View {
@State private var rippleScale: CGFloat = 0.3
@State private var rippleOpacity: Double = 0
@State private var isHovering: Bool = false
// Rotating index into `activeSessions` for the cycling display. Only
// advances when `shouldCycle` is true (≥2 active sessions and nothing
// more urgent on screen). Stays bounded by % so a session disappearing
// mid-rotation doesn't crash.
@State private var cycleIndex: Int = 0

private static let glowColor = Color(red: 0.4, green: 0.85, blue: 1.0)
private static let recentEventWindow: TimeInterval = 5 * 60
private static let cyclePeriod: TimeInterval = 5.0

var body: some View {
HStack(spacing: 10) {
Expand Down Expand Up @@ -160,6 +166,8 @@ struct CompactView: View {
.lineLimit(1)
}
}
} else if shouldCycle {
cyclingActiveSession
} else if let active = mostRecentActive {
Text(displayName(active))
.font(.system(size: 11, weight: .medium))
Expand All @@ -172,6 +180,50 @@ struct CompactView: View {
}
}

// When ≥2 sessions are active and no busy session / recent event is
// demanding the spotlight, cycle through each active session every
// `cyclePeriod` seconds so the user can see all of their parallel
// agents at a glance instead of just one. Soft crossfade between
// entries. Snap-back to the single-session display is automatic —
// `shouldCycle` flips false the moment busy or recent-event branches
// take over, SwiftUI tears down this view, and the Timer.publish
// subscription cancels with it.
@ViewBuilder
private var cyclingActiveSession: some View {
let pool = activeSessions
let session = pool[cycleIndex % max(pool.count, 1)]
HStack(spacing: 4) {
Text(displayName(session))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Text("·")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
if let stats = transcriptStats(for: session) {
Text(Self.formatTokens(stats.tokens))
.font(.system(size: 10, weight: .medium).monospacedDigit())
.foregroundStyle(.secondary)
} else {
let position = (pool.firstIndex { $0.pid == session.pid } ?? 0) + 1
Text("\(position)/\(pool.count)")
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.tertiary)
}
}
.id(session.pid)
.transition(.opacity)
.onReceive(Timer.publish(every: Self.cyclePeriod, on: .main, in: .common).autoconnect()) { _ in
// Re-check shouldCycle on every tick — pool size can change as
// sessions finish or new ones start. Skip during drag so we
// don't compete with AppKit's mouse handler.
guard shouldCycle, !nav.compactDragging else { return }
withAnimation(.easeInOut(duration: 0.45)) {
cycleIndex = (cycleIndex + 1) % max(activeSessions.count, 1)
}
}
}

// Passive quota-stress signal for the mascot. True when either the 5h
// session window or the 7d weekly utilization is ≥75%. The mascot
// shows a tired/worried expression so the user sees the budget
Expand Down Expand Up @@ -313,6 +365,22 @@ struct CompactView: View {
sessions.sessions.first { $0.status == .active }
}

// Stable, PID-sorted list of every currently-active session. Drives
// the pill's rotation when ≥2 are alive at the same time.
private var activeSessions: [Session] {
sessions.sessions
.filter { $0.status == .active }
.sorted { $0.pid < $1.pid }
}

// Rotate sessions only when nothing more important is on screen and
// there's actually something to rotate through.
private var shouldCycle: Bool {
busiestSession == nil
&& recentEvent == nil
&& activeSessions.count >= 2
}

private func transcriptStats(for s: Session) -> TranscriptStats? {
if let id = s.claudeSessionID, let stats = nav.claudeSessionStats[id] {
return stats
Expand Down
Loading