From a1d14f8c8a6cc54f1df1e9a4b45a555c82024dcd Mon Sep 17 00:00:00 2001 From: Lee Rosen <96027741+tsconfigdotjson@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:44:31 -0400 Subject: [PATCH 1/4] Prepare for open source: add tests, strip comments, add MIT license - Add 111 Swift tests across ANSIParser, TerminalBuffer, and Persistence - Strip all comments from source files (Swift, TSX, CSS) - Add MIT license to README - Add test target to Package.swift Co-Authored-By: Claude Opus 4.6 (1M context) --- Package.swift | 4 + README.md | 4 + Sources/DevtailKit/ANSIParser.swift | 34 +- Sources/DevtailKit/ProcessRunner.swift | 9 - Sources/DevtailKit/TerminalBuffer.swift | 4 - Sources/DevtailKit/TerminalOutputView.swift | 18 +- Sources/devtail/AppDelegate.swift | 1 - Sources/devtail/AppNotifications.swift | 4 - Sources/devtail/ContentView.swift | 7 - Sources/devtail/Models.swift | 8 - Sources/devtail/PopOutWindowManager.swift | 4 - Sources/devtail/ProcessCardView.swift | 5 - Sources/devtail/ProcessDetailView.swift | 4 - Sources/devtail/ProcessFormView.swift | 1 - Sources/devtail/ProcessStore.swift | 10 +- Tests/DevtailKitTests/ANSIParserTests.swift | 586 ++++++++++++++++++ Tests/DevtailKitTests/PersistenceTests.swift | 246 ++++++++ .../DevtailKitTests/TerminalBufferTests.swift | 331 ++++++++++ web/src/App.css | 6 - web/src/App.tsx | 9 - 20 files changed, 1183 insertions(+), 112 deletions(-) create mode 100644 Tests/DevtailKitTests/ANSIParserTests.swift create mode 100644 Tests/DevtailKitTests/PersistenceTests.swift create mode 100644 Tests/DevtailKitTests/TerminalBufferTests.swift diff --git a/Package.swift b/Package.swift index 15d73b3..0b36f84 100644 --- a/Package.swift +++ b/Package.swift @@ -15,5 +15,9 @@ let package = Package( name: "devtail", dependencies: ["DevtailKit"] ), + .testTarget( + name: "DevtailKitTests", + dependencies: ["DevtailKit"] + ), ] ) diff --git a/README.md b/README.md index ce504f0..dbff3e2 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,7 @@ swift run ``` The app appears as a terminal icon in your menu bar. + +## License + +MIT diff --git a/Sources/DevtailKit/ANSIParser.swift b/Sources/DevtailKit/ANSIParser.swift index cbd111b..7237bde 100644 --- a/Sources/DevtailKit/ANSIParser.swift +++ b/Sources/DevtailKit/ANSIParser.swift @@ -1,12 +1,10 @@ import Foundation -// MARK: - Types - public enum ANSIColor: Sendable, Hashable { case `default` - case standard(UInt8) // 0-7: black, red, green, yellow, blue, magenta, cyan, white - case bright(UInt8) // 0-7: bright variants - case palette(UInt8) // 0-255 + case standard(UInt8) + case bright(UInt8) + case palette(UInt8) case rgb(UInt8, UInt8, UInt8) } @@ -41,8 +39,6 @@ public enum TerminalAction: Sendable { case cursorUp(Int) } -// MARK: - Parser - public struct ANSIParser: Sendable { public var currentStyle = ANSIStyle() @@ -64,7 +60,7 @@ public struct ANSIParser: Sendable { let ch = input[i] switch ch { - case "\u{1B}": // ESC + case "\u{1B}": flushText() let next = input.index(after: i) if next < input.endIndex && input[next] == "[" { @@ -77,7 +73,6 @@ public struct ANSIParser: Sendable { continue } } - // Skip unrecognized escape — advance past ESC i = input.index(after: i) continue @@ -97,7 +92,6 @@ public struct ANSIParser: Sendable { default: if let ascii = ch.asciiValue, ascii < 32, ascii != 9 { - // Skip control characters except tab } else { textBuf.append(ch) } @@ -110,8 +104,6 @@ public struct ANSIParser: Sendable { return actions } - // MARK: - CSI Parsing - private mutating func parseCSI(_ input: String, from start: String.Index) -> (TerminalAction?, String.Index)? { var paramStr = "" var i = start @@ -129,22 +121,22 @@ public struct ANSIParser: Sendable { i = input.index(after: i) } - return nil // Incomplete sequence + return nil } private mutating func handleCSI(params: String, final: Character) -> TerminalAction? { let parts = params.split(separator: ";", omittingEmptySubsequences: false).map { Int($0) } switch final { - case "m": // SGR — Select Graphic Rendition + case "m": applySGR(parts) return nil - case "K": // Erase in Line + case "K": let mode = parts.first.flatMap({ $0 }) ?? 0 return mode == 2 ? .eraseLine : .eraseToEndOfLine - case "A": // Cursor Up + case "A": let n = parts.first.flatMap({ $0 }) ?? 1 return .cursorUp(max(1, n)) @@ -153,8 +145,6 @@ public struct ANSIParser: Sendable { } } - // MARK: - SGR - private mutating func applySGR(_ params: [Int?]) { if params.isEmpty || (params.count == 1 && params[0] == nil) { currentStyle = ANSIStyle() @@ -179,7 +169,6 @@ public struct ANSIParser: Sendable { case 24: currentStyle.underline = false case 29: currentStyle.strikethrough = false - // Foreground colors case 30...37: currentStyle.foreground = .standard(UInt8(code - 30)) case 38: @@ -190,7 +179,6 @@ public struct ANSIParser: Sendable { case 39: currentStyle.foreground = .default - // Background colors case 40...47: currentStyle.background = .standard(UInt8(code - 40)) case 48: @@ -201,11 +189,9 @@ public struct ANSIParser: Sendable { case 49: currentStyle.background = .default - // Bright foreground case 90...97: currentStyle.foreground = .bright(UInt8(code - 90)) - // Bright background case 100...107: currentStyle.background = .bright(UInt8(code - 100)) @@ -222,10 +208,10 @@ public struct ANSIParser: Sendable { let mode = params[index] ?? 0 switch mode { - case 5: // 256-color palette + case 5: guard index + 1 < params.count, let n = params[index + 1] else { return nil } return (.palette(UInt8(clamping: n)), 2) - case 2: // Truecolor RGB + case 2: guard index + 3 < params.count else { return nil } let r = UInt8(clamping: params[index + 1] ?? 0) let g = UInt8(clamping: params[index + 2] ?? 0) diff --git a/Sources/DevtailKit/ProcessRunner.swift b/Sources/DevtailKit/ProcessRunner.swift index e910628..673d1aa 100644 --- a/Sources/DevtailKit/ProcessRunner.swift +++ b/Sources/DevtailKit/ProcessRunner.swift @@ -4,8 +4,6 @@ import Foundation public final class ProcessRunner { private var process: Process? private var readTask: Task? - /// Stored separately because the shell may exit before its children. - /// We need the PID to kill the entire process group even after the shell is gone. private var launchedPID: Int32 = 0 public init() {} @@ -99,8 +97,6 @@ public final class ProcessRunner { let pid = launchedPID guard pid != 0 else { return } - // Always kill the process group — the shell may have exited - // but npm/node/next-server children can still be alive. kill(-pid, SIGTERM) Task.detached { @@ -114,24 +110,19 @@ public final class ProcessRunner { readTask = nil } - /// Synchronous stop that blocks until the process group is dead. - /// Only use during app quit — blocks the main thread. public func stopSync(timeout: TimeInterval = 2.0) { let pid = launchedPID guard pid != 0 else { return } kill(-pid, SIGTERM) - // Poll until the group leader is gone let deadline = Date().addingTimeInterval(timeout) while kill(pid, 0) == 0 && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) } - // Force kill any survivors if kill(pid, 0) == 0 { kill(-pid, SIGKILL) - // Brief wait for kernel cleanup Thread.sleep(forTimeInterval: 0.05) } diff --git a/Sources/DevtailKit/TerminalBuffer.swift b/Sources/DevtailKit/TerminalBuffer.swift index c07a705..482abc4 100644 --- a/Sources/DevtailKit/TerminalBuffer.swift +++ b/Sources/DevtailKit/TerminalBuffer.swift @@ -1,7 +1,5 @@ import SwiftUI -// MARK: - Terminal Line - public struct TerminalLine: Sendable, Identifiable { public let id: Int public var spans: [StyledSpan] @@ -20,8 +18,6 @@ public struct TerminalLine: Sendable, Identifiable { } } -// MARK: - Terminal Buffer - @MainActor @Observable public final class TerminalBuffer { diff --git a/Sources/DevtailKit/TerminalOutputView.swift b/Sources/DevtailKit/TerminalOutputView.swift index aa09a7e..6535b09 100644 --- a/Sources/DevtailKit/TerminalOutputView.swift +++ b/Sources/DevtailKit/TerminalOutputView.swift @@ -1,8 +1,6 @@ import AppKit import SwiftUI -// MARK: - Full Output View (NSTextView-backed for performance + selection) - public struct TerminalOutputView: View { let buffer: TerminalBuffer var fontSize: CGFloat @@ -13,8 +11,7 @@ public struct TerminalOutputView: View { } public var body: some View { - // Reading version registers @Observable tracking - let _ = buffer.version // swiftlint:disable:this redundant_discardable_let + let _ = buffer.version TerminalNSView(buffer: buffer, version: buffer.version, fontSize: fontSize) } } @@ -65,7 +62,6 @@ private struct TerminalNSView: NSViewRepresentable { class Coordinator { weak var textView: NSTextView? - // Cached objects — avoid recreating on every 50ms update private var cachedFontSize: CGFloat = 0 private var regularFont: NSFont? private var boldFont: NSFont? @@ -77,9 +73,6 @@ private struct TerminalNSView: NSViewRepresentable { guard let storage = textView.textStorage else { return } guard let scrollView = textView.enclosingScrollView else { return } - // Check if we're at (or near) the bottom before updating content. - // If so, follow new output. If the user scrolled up, stay put. - // On first update, always scroll to bottom (new view showing existing content). let clipView = scrollView.contentView let maxScrollY = max(textView.frame.height - clipView.bounds.height, 0) let isAtBottom = isFirstUpdate || clipView.bounds.origin.y >= maxScrollY - 20 @@ -94,9 +87,6 @@ private struct TerminalNSView: NSViewRepresentable { if isAtBottom { if isFirstUpdate { isFirstUpdate = false - // Defer scroll until after the view has been laid out by the window system. - // On first render, geometry is zero-sized so an immediate scroll has no effect. - // We need two run-loop passes: one for the window to lay out, one to scroll. DispatchQueue.main.async { [weak textView] in guard let textView, let sv = textView.enclosingScrollView else { return } sv.layoutSubtreeIfNeeded() @@ -168,8 +158,6 @@ private struct TerminalNSView: NSViewRepresentable { } } -// MARK: - Preview Text (for cards, compact display) - public struct TerminalPreviewText: View { let buffer: TerminalBuffer var lineLimit: Int @@ -226,8 +214,6 @@ public struct TerminalPreviewText: View { } } -// MARK: - ANSIColor → SwiftUI Color - extension ANSIColor { public var swiftUIColor: Color { switch self { @@ -299,8 +285,6 @@ extension ANSIColor { } } -// MARK: - ANSIColor → NSColor - extension ANSIColor { public var nsColor: NSColor { switch self { diff --git a/Sources/devtail/AppDelegate.swift b/Sources/devtail/AppDelegate.swift index e8ea87f..1283dd9 100644 --- a/Sources/devtail/AppDelegate.swift +++ b/Sources/devtail/AppDelegate.swift @@ -7,7 +7,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var signalSource: DispatchSourceSignal? func applicationDidFinishLaunching(_ notification: Notification) { - // Ensure child processes are cleaned up even if we're killed with SIGTERM signal(SIGTERM, SIG_IGN) let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) source.setEventHandler { [weak self] in diff --git a/Sources/devtail/AppNotifications.swift b/Sources/devtail/AppNotifications.swift index d32d028..750e4b4 100644 --- a/Sources/devtail/AppNotifications.swift +++ b/Sources/devtail/AppNotifications.swift @@ -26,8 +26,6 @@ enum AppNotifications { } } - // MARK: - Bundled .app — UNUserNotificationCenter - private static func sendUNNotification(title: String, body: String) { let content = UNMutableNotificationContent() content.title = title @@ -42,8 +40,6 @@ enum AppNotifications { UNUserNotificationCenter.current().add(request) } - // MARK: - Unbundled executable — osascript fallback - private static func sendScriptNotification(title: String, body: String) { let escaped = { (s: String) -> String in s.replacingOccurrences(of: "\\", with: "\\\\") diff --git a/Sources/devtail/ContentView.swift b/Sources/devtail/ContentView.swift index abe34a6..0acf2c9 100644 --- a/Sources/devtail/ContentView.swift +++ b/Sources/devtail/ContentView.swift @@ -28,8 +28,6 @@ struct ContentView: View { .background(.background) } - // MARK: - Header - private var headerBar: some View { HStack(spacing: 10) { if viewState != .list { @@ -78,8 +76,6 @@ struct ContentView: View { .animation(.easeInOut(duration: 0.2), value: viewState) } - // MARK: - Content - @ViewBuilder private var contentArea: some View { switch viewState { @@ -189,9 +185,6 @@ struct ContentView: View { .padding(.horizontal, 24) } - // MARK: - Footer - - /// SMAppService requires a proper .app bundle to work. private var canManageLaunchAtLogin: Bool { Bundle.main.bundlePath.hasSuffix(".app") } diff --git a/Sources/devtail/Models.swift b/Sources/devtail/Models.swift index 2aaa8c7..641c2c3 100644 --- a/Sources/devtail/Models.swift +++ b/Sources/devtail/Models.swift @@ -18,7 +18,6 @@ final class DevProcess: Identifiable { private var auxiliaryRunners: [UUID: ProcessRunner] = [:] private var userStopped = false - /// Called when running state changes so the store can persist. var onStateChange: (() -> Void)? init( @@ -44,7 +43,6 @@ final class DevProcess: Identifiable { return buf } - /// Remove buffers for auxiliary commands that no longer exist. func cleanupAuxiliaryBuffers() { let validIDs = Set(auxiliaryCommands.map(\.id)) for key in auxiliaryBuffers.keys where !validIDs.contains(key) { @@ -52,8 +50,6 @@ final class DevProcess: Identifiable { } } - // MARK: - Lifecycle - func start() { guard !isRunning else { return } userStopped = false @@ -100,8 +96,6 @@ final class DevProcess: Identifiable { onStateChange?() } - /// Synchronous stop that blocks until all processes are dead. - /// Only use during app quit. func forceStop() { userStopped = true runner?.stopSync() @@ -117,8 +111,6 @@ final class DevProcess: Identifiable { if isRunning { stop() } else { start() } } - // MARK: - Auxiliary Commands - private func startAuxiliaryCommands() { for aux in auxiliaryCommands { let auxRunner = ProcessRunner() diff --git a/Sources/devtail/PopOutWindowManager.swift b/Sources/devtail/PopOutWindowManager.swift index 1eb52ff..ff986da 100644 --- a/Sources/devtail/PopOutWindowManager.swift +++ b/Sources/devtail/PopOutWindowManager.swift @@ -6,14 +6,12 @@ import SwiftUI final class PopOutWindowManager { static let shared = PopOutWindowManager() - /// Keyed by buffer's ObjectIdentifier so each buffer gets one window. private var windows: [ObjectIdentifier: NSWindow] = [:] private var delegates: [ObjectIdentifier: WindowCloseDelegate] = [:] func openWindow(buffer: TerminalBuffer, title: String) { let key = ObjectIdentifier(buffer) - // If window already exists, bring it to front if let existing = windows[key], existing.isVisible { existing.makeKeyAndOrderFront(nil) NSApp.activate() @@ -77,8 +75,6 @@ final class PopOutWindowManager { } } -// MARK: - Window Close Delegate - private final class WindowCloseDelegate: NSObject, NSWindowDelegate, @unchecked Sendable { let onClose: @MainActor () -> Void diff --git a/Sources/devtail/ProcessCardView.swift b/Sources/devtail/ProcessCardView.swift index 9657a18..236fafe 100644 --- a/Sources/devtail/ProcessCardView.swift +++ b/Sources/devtail/ProcessCardView.swift @@ -12,7 +12,6 @@ struct ProcessCardView: View { var body: some View { Button(action: onSelect) { VStack(alignment: .leading, spacing: 8) { - // Name and status HStack { Text(process.name) .font(.system(size: 13, weight: .semibold)) @@ -23,7 +22,6 @@ struct ProcessCardView: View { StatusDot(isRunning: process.isRunning) } - // Command output preview if process.buffer.hasContent { TerminalBlock { TerminalPreviewText( @@ -34,7 +32,6 @@ struct ProcessCardView: View { } } - // Auxiliary command previews ForEach(process.auxiliaryCommands) { aux in let auxBuf = process.bufferFor(auxiliary: aux.id) VStack(alignment: .leading, spacing: 2) { @@ -94,8 +91,6 @@ struct ProcessCardView: View { } } -// MARK: - Shared Components - struct StatusDot: View { let isRunning: Bool diff --git a/Sources/devtail/ProcessDetailView.swift b/Sources/devtail/ProcessDetailView.swift index a55e428..92ac2f4 100644 --- a/Sources/devtail/ProcessDetailView.swift +++ b/Sources/devtail/ProcessDetailView.swift @@ -11,7 +11,6 @@ struct ProcessDetailView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // Process info header VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text(process.name) @@ -68,7 +67,6 @@ struct ProcessDetailView: View { .padding(.horizontal, 16) .padding(.vertical, 10) - // Tab picker for output sources if !process.auxiliaryCommands.isEmpty { Picker("", selection: $selectedTab) { Text("Output").tag(0) @@ -82,7 +80,6 @@ struct ProcessDetailView: View { .padding(.bottom, 8) } - // Terminal output Group { if currentBuffer.hasContent { TerminalOutputView(buffer: currentBuffer) @@ -135,7 +132,6 @@ struct ProcessDetailView: View { .padding(.horizontal, 12) .padding(.bottom, 12) } - // Reset tab if aux commands were removed during edit .onChange(of: process.auxiliaryCommands.count) { if selectedTab > process.auxiliaryCommands.count { selectedTab = 0 diff --git a/Sources/devtail/ProcessFormView.swift b/Sources/devtail/ProcessFormView.swift index 335ca74..7790550 100644 --- a/Sources/devtail/ProcessFormView.swift +++ b/Sources/devtail/ProcessFormView.swift @@ -42,7 +42,6 @@ struct ProcessFormView: View { label: "Working Directory", placeholder: "~/projects/app", text: $workingDirectory, monospaced: true) } - // Auxiliary commands section VStack(alignment: .leading, spacing: 8) { HStack { Text("LOG WATCHERS") diff --git a/Sources/devtail/ProcessStore.swift b/Sources/devtail/ProcessStore.swift index 6b207b4..059c6f6 100644 --- a/Sources/devtail/ProcessStore.swift +++ b/Sources/devtail/ProcessStore.swift @@ -21,12 +21,10 @@ final class ProcessStore { ) } - // Wire up persistence callbacks for process in processes { process.onStateChange = { [weak self] in self?.save() } } - // Defer auto-start so views have time to initialize let autoStartIDs = Set(saved.filter(\.wasRunning).map(\.id)) if !autoStartIDs.isEmpty { Task { @MainActor [weak self] in @@ -39,8 +37,6 @@ final class ProcessStore { } } - // MARK: - CRUD - func addProcess(name: String, command: String, workingDirectory: String, auxiliaryCommands: [AuxiliaryCommand]) { let process = DevProcess( name: name, @@ -83,18 +79,14 @@ final class ProcessStore { save() } - // MARK: - Persistence - func save() { guard !isQuitting else { return } Persistence.save(processes) } - /// Save state then synchronously kill all processes. - /// Blocks until every child process is dead — only call during app quit. func stopAllForQuit() { isQuitting = true - Persistence.save(processes) // Captures wasRunning before we stop + Persistence.save(processes) for process in processes where process.isRunning { process.forceStop() } diff --git a/Tests/DevtailKitTests/ANSIParserTests.swift b/Tests/DevtailKitTests/ANSIParserTests.swift new file mode 100644 index 0000000..8c3397d --- /dev/null +++ b/Tests/DevtailKitTests/ANSIParserTests.swift @@ -0,0 +1,586 @@ +import Testing + +@testable import DevtailKit + +struct ANSIParserTests { + + // MARK: - Helper + + /// Shorthand to parse a string and return actions. + private func parse(_ input: String) -> [TerminalAction] { + var parser = ANSIParser() + return parser.parse(input) + } + + /// Extract the first .text action's span, if any. + private func firstSpan(_ actions: [TerminalAction]) -> StyledSpan? { + for action in actions { + if case .text(let span) = action { return span } + } + return nil + } + + /// Collect all .text spans from actions. + private func allSpans(_ actions: [TerminalAction]) -> [StyledSpan] { + actions.compactMap { + if case .text(let span) = $0 { return span } + return nil + } + } + + // MARK: - Plain text + + @Test func plainTextPassesThrough() { + let actions = parse("hello world") + #expect(actions.count == 1) + guard case .text(let span) = actions[0] else { + Issue.record("Expected .text action") + return + } + #expect(span.text == "hello world") + #expect(span.style == ANSIStyle()) + } + + @Test func emptyStringProducesNoActions() { + let actions = parse("") + #expect(actions.isEmpty) + } + + // MARK: - Newline / carriage return + + @Test func newlineProducesNewlineAction() { + let actions = parse("\n") + #expect(actions.count == 1) + guard case .newline = actions[0] else { + Issue.record("Expected .newline") + return + } + } + + @Test func carriageReturnProducesCarriageReturnAction() { + let actions = parse("\r") + #expect(actions.count == 1) + guard case .carriageReturn = actions[0] else { + Issue.record("Expected .carriageReturn") + return + } + } + + @Test func crlfGraphemeClusterIsFiltered() { + // In Swift, \r\n forms a single Character grapheme cluster that does NOT + // match case "\r" or case "\n" in the parser's switch. Its asciiValue is 10 + // (newline), which is < 32 and != 9, so it gets filtered as a control char. + // This is a known limitation of character-level parsing in Swift. + let input = String(Unicode.Scalar(0x0D)) + String(Unicode.Scalar(0x0A)) + let actions = parse(input) + // CRLF grapheme is filtered out, producing no actions + #expect(actions.isEmpty) + } + + @Test func textBeforeAndAfterNewline() { + let actions = parse("abc\ndef") + #expect(actions.count == 3) + if case .text(let s1) = actions[0] { #expect(s1.text == "abc") } + guard case .newline = actions[1] else { + Issue.record("Expected .newline") + return + } + if case .text(let s2) = actions[2] { #expect(s2.text == "def") } + } + + // MARK: - Standard foreground colors (30-37) + + @Test func standardForegroundColors() { + for code in 30...37 { + let actions = parse("\u{1B}[\(code)mX") + let span = firstSpan(actions) + #expect(span != nil, "Code \(code) should produce text") + #expect(span?.style.foreground == .standard(UInt8(code - 30))) + } + } + + // MARK: - Standard background colors (40-47) + + @Test func standardBackgroundColors() { + for code in 40...47 { + let actions = parse("\u{1B}[\(code)mX") + let span = firstSpan(actions) + #expect(span != nil, "Code \(code) should produce text") + #expect(span?.style.background == .standard(UInt8(code - 40))) + } + } + + // MARK: - Bright foreground colors (90-97) + + @Test func brightForegroundColors() { + for code in 90...97 { + let actions = parse("\u{1B}[\(code)mX") + let span = firstSpan(actions) + #expect(span != nil, "Code \(code) should produce text") + #expect(span?.style.foreground == .bright(UInt8(code - 90))) + } + } + + // MARK: - Bright background colors (100-107) + + @Test func brightBackgroundColors() { + for code in 100...107 { + let actions = parse("\u{1B}[\(code)mX") + let span = firstSpan(actions) + #expect(span != nil, "Code \(code) should produce text") + #expect(span?.style.background == .bright(UInt8(code - 100))) + } + } + + // MARK: - 256-color palette + + @Test func foreground256Color() { + let actions = parse("\u{1B}[38;5;123mHi") + let span = firstSpan(actions) + #expect(span?.style.foreground == .palette(123)) + #expect(span?.text == "Hi") + } + + @Test func background256Color() { + let actions = parse("\u{1B}[48;5;200mBg") + let span = firstSpan(actions) + #expect(span?.style.background == .palette(200)) + #expect(span?.text == "Bg") + } + + @Test func foreground256ColorZero() { + let actions = parse("\u{1B}[38;5;0mX") + let span = firstSpan(actions) + #expect(span?.style.foreground == .palette(0)) + } + + @Test func foreground256Color255() { + let actions = parse("\u{1B}[38;5;255mX") + let span = firstSpan(actions) + #expect(span?.style.foreground == .palette(255)) + } + + // MARK: - RGB truecolor + + @Test func foregroundRGBColor() { + let actions = parse("\u{1B}[38;2;255;128;0mTruecolor") + let span = firstSpan(actions) + #expect(span?.style.foreground == .rgb(255, 128, 0)) + #expect(span?.text == "Truecolor") + } + + @Test func backgroundRGBColor() { + let actions = parse("\u{1B}[48;2;10;20;30mBgRGB") + let span = firstSpan(actions) + #expect(span?.style.background == .rgb(10, 20, 30)) + #expect(span?.text == "BgRGB") + } + + @Test func rgbColorAllZeros() { + let actions = parse("\u{1B}[38;2;0;0;0mBlack") + let span = firstSpan(actions) + #expect(span?.style.foreground == .rgb(0, 0, 0)) + } + + @Test func rgbColorAllMax() { + let actions = parse("\u{1B}[38;2;255;255;255mWhite") + let span = firstSpan(actions) + #expect(span?.style.foreground == .rgb(255, 255, 255)) + } + + // MARK: - Text styles + + @Test func boldStyle() { + let actions = parse("\u{1B}[1mBold") + let span = firstSpan(actions) + #expect(span?.style.bold == true) + #expect(span?.text == "Bold") + } + + @Test func dimStyle() { + let actions = parse("\u{1B}[2mDim") + let span = firstSpan(actions) + #expect(span?.style.dim == true) + } + + @Test func italicStyle() { + let actions = parse("\u{1B}[3mItalic") + let span = firstSpan(actions) + #expect(span?.style.italic == true) + } + + @Test func underlineStyle() { + let actions = parse("\u{1B}[4mUnderline") + let span = firstSpan(actions) + #expect(span?.style.underline == true) + } + + @Test func strikethroughStyle() { + let actions = parse("\u{1B}[9mStrike") + let span = firstSpan(actions) + #expect(span?.style.strikethrough == true) + } + + // MARK: - Reset + + @Test func resetClearsAllStyles() { + var parser = ANSIParser() + // Set bold + red foreground + _ = parser.parse("\u{1B}[1;31m") + // Now reset + let actions = parser.parse("\u{1B}[0mAfterReset") + let span = firstSpan(actions) + #expect(span?.style == ANSIStyle()) + #expect(span?.text == "AfterReset") + } + + @Test func emptySGRResetsStyle() { + var parser = ANSIParser() + _ = parser.parse("\u{1B}[1;31m") + let actions = parser.parse("\u{1B}[mReset") + let span = firstSpan(actions) + #expect(span?.style == ANSIStyle()) + } + + // MARK: - Individual style resets + + @Test func code22ResetsBoldAndDim() { + var parser = ANSIParser() + _ = parser.parse("\u{1B}[1;2m") + let actions = parser.parse("\u{1B}[22mText") + let span = firstSpan(actions) + #expect(span?.style.bold == false) + #expect(span?.style.dim == false) + } + + @Test func code23ResetsItalic() { + var parser = ANSIParser() + _ = parser.parse("\u{1B}[3m") + let actions = parser.parse("\u{1B}[23mText") + let span = firstSpan(actions) + #expect(span?.style.italic == false) + } + + @Test func code24ResetsUnderline() { + var parser = ANSIParser() + _ = parser.parse("\u{1B}[4m") + let actions = parser.parse("\u{1B}[24mText") + let span = firstSpan(actions) + #expect(span?.style.underline == false) + } + + @Test func code29ResetsStrikethrough() { + var parser = ANSIParser() + _ = parser.parse("\u{1B}[9m") + let actions = parser.parse("\u{1B}[29mText") + let span = firstSpan(actions) + #expect(span?.style.strikethrough == false) + } + + // MARK: - Default foreground / background + + @Test func code39ResetsDefaultForeground() { + var parser = ANSIParser() + _ = parser.parse("\u{1B}[31m") + let actions = parser.parse("\u{1B}[39mText") + let span = firstSpan(actions) + #expect(span?.style.foreground == .default) + } + + @Test func code49ResetsDefaultBackground() { + var parser = ANSIParser() + _ = parser.parse("\u{1B}[42m") + let actions = parser.parse("\u{1B}[49mText") + let span = firstSpan(actions) + #expect(span?.style.background == .default) + } + + // MARK: - Erase line + + @Test func eraseLineCode2K() { + let actions = parse("\u{1B}[2K") + #expect(actions.count == 1) + guard case .eraseLine = actions[0] else { + Issue.record("Expected .eraseLine") + return + } + } + + @Test func eraseToEndOfLineCodeK() { + let actions = parse("\u{1B}[K") + #expect(actions.count == 1) + guard case .eraseToEndOfLine = actions[0] else { + Issue.record("Expected .eraseToEndOfLine") + return + } + } + + @Test func eraseToEndOfLineCode0K() { + let actions = parse("\u{1B}[0K") + #expect(actions.count == 1) + guard case .eraseToEndOfLine = actions[0] else { + Issue.record("Expected .eraseToEndOfLine") + return + } + } + + // MARK: - Cursor up + + @Test func cursorUpDefault() { + let actions = parse("\u{1B}[A") + #expect(actions.count == 1) + guard case .cursorUp(let n) = actions[0] else { + Issue.record("Expected .cursorUp") + return + } + #expect(n == 1) + } + + @Test func cursorUpExplicitCount() { + let actions = parse("\u{1B}[3A") + #expect(actions.count == 1) + guard case .cursorUp(let n) = actions[0] else { + Issue.record("Expected .cursorUp") + return + } + #expect(n == 3) + } + + @Test func cursorUpZeroBecomesOne() { + let actions = parse("\u{1B}[0A") + guard case .cursorUp(let n) = actions[0] else { + Issue.record("Expected .cursorUp") + return + } + #expect(n == 1) + } + + // MARK: - Multiple SGR params in one sequence + + @Test func multipleSGRParams() { + let actions = parse("\u{1B}[1;31;42mCombined") + let span = firstSpan(actions) + #expect(span?.style.bold == true) + #expect(span?.style.foreground == .standard(1)) // red + #expect(span?.style.background == .standard(2)) // green + #expect(span?.text == "Combined") + } + + @Test func multipleSGRParamsItalicBrightCyan() { + let actions = parse("\u{1B}[3;96mTest") + let span = firstSpan(actions) + #expect(span?.style.italic == true) + #expect(span?.style.foreground == .bright(6)) // bright cyan + } + + // MARK: - Control characters + + @Test func controlCharactersAreFiltered() { + // Characters below 32 (except tab) should be stripped + let input = "A\u{01}B\u{02}C\u{07}D" + let actions = parse(input) + let span = firstSpan(actions) + #expect(span?.text == "ABCD") + } + + @Test func tabCharacterPassesThrough() { + let actions = parse("A\tB") + let span = firstSpan(actions) + #expect(span?.text == "A\tB") + } + + // MARK: - Incomplete / malformed sequences + + @Test func incompleteEscapeSequenceIsSkipped() { + // ESC followed by end of string + let actions = parse("Hello\u{1B}") + #expect(actions.count == 1) + let span = firstSpan(actions) + #expect(span?.text == "Hello") + } + + @Test func escNotFollowedByBracketSkips() { + // ESC followed by non-bracket char + let actions = parse("A\u{1B}XB") + // "A" is flushed before ESC, ESC+X advances past ESC, then "XB" continues + let spans = allSpans(actions) + let combined = spans.map(\.text).joined() + #expect(combined == "AXB") + } + + @Test func incompleteCSISequenceIsSkipped() { + // ESC[ with digits but no final letter + let actions = parse("Hi\u{1B}[31") + let span = firstSpan(actions) + #expect(span?.text == "Hi") + } + + // MARK: - Style persistence across parse calls + + @Test func stylePersistsAcrossParseCalls() { + var parser = ANSIParser() + _ = parser.parse("\u{1B}[1;31m") // bold + red + let actions = parser.parse("StyledText") + let span = firstSpan(actions) + #expect(span?.style.bold == true) + #expect(span?.style.foreground == .standard(1)) + #expect(span?.text == "StyledText") + } + + @Test func stylePersistsThenResets() { + var parser = ANSIParser() + let a1 = parser.parse("\u{1B}[32mGreen") + let s1 = firstSpan(a1) + #expect(s1?.style.foreground == .standard(2)) + + let a2 = parser.parse("\u{1B}[0mPlain") + let s2 = firstSpan(a2) + #expect(s2?.style == ANSIStyle()) + } + + // MARK: - Mixed text and escapes + + @Test func mixedTextAndEscapes() { + let actions = parse("Hello \u{1B}[31mWorld\u{1B}[0m!") + let spans = allSpans(actions) + #expect(spans.count == 3) + #expect(spans[0].text == "Hello ") + #expect(spans[0].style.foreground == .default) + #expect(spans[1].text == "World") + #expect(spans[1].style.foreground == .standard(1)) + #expect(spans[2].text == "!") + #expect(spans[2].style == ANSIStyle()) + } + + @Test func escapeSequenceAtStartOfString() { + let actions = parse("\u{1B}[34mBlue text") + let span = firstSpan(actions) + #expect(span?.style.foreground == .standard(4)) + #expect(span?.text == "Blue text") + } + + @Test func escapeSequenceAtEndOfString() { + let actions = parse("Text\u{1B}[0m") + let spans = allSpans(actions) + #expect(spans.count == 1) + #expect(spans[0].text == "Text") + } + + @Test func multipleNewlinesInSequence() { + let actions = parse("\n\n\n") + #expect(actions.count == 3) + for action in actions { + guard case .newline = action else { + Issue.record("Expected all .newline") + return + } + } + } + + @Test func textWithCRLFGraphemeClusters() { + // CRLF grapheme clusters are filtered (see crlfGraphemeClusterIsFiltered), + // so text on either side merges into one span. + let crlf = String(Unicode.Scalar(0x0D)) + String(Unicode.Scalar(0x0A)) + let input = "line1" + crlf + "line2" + let actions = parse(input) + #expect(actions.count == 1) + if case .text(let s) = actions[0] { #expect(s.text == "line1line2") } + } + + @Test func separateCRAndLFProduceBothActions() { + // When \r and \n arrive in separate parse() calls, they work as expected + var parser = ANSIParser() + let a1 = parser.parse("line1\r") + let a2 = parser.parse("\nline2") + // First parse: text("line1") + carriageReturn + #expect(a1.count == 2) + if case .text(let s) = a1[0] { #expect(s.text == "line1") } + guard case .carriageReturn = a1[1] else { + Issue.record("Expected .carriageReturn") + return + } + // Second parse: newline + text("line2") + #expect(a2.count == 2) + guard case .newline = a2[0] else { + Issue.record("Expected .newline") + return + } + if case .text(let s) = a2[1] { #expect(s.text == "line2") } + } + + @Test func carriageReturnFollowedByText() { + let actions = parse("old\rnew") + #expect(actions.count == 3) + if case .text(let s) = actions[0] { #expect(s.text == "old") } + guard case .carriageReturn = actions[1] else { + Issue.record("Expected .carriageReturn") + return + } + if case .text(let s) = actions[2] { #expect(s.text == "new") } + } + + // MARK: - Extended color edge cases + + @Test func extendedColor38WithInsufficientParams() { + // 38;5 without the color number -- should not crash + let actions = parse("\u{1B}[38;5mX") + let span = firstSpan(actions) + // Parser should still produce text, foreground stays default + #expect(span?.text == "X") + } + + @Test func extendedColor38ModeUnknown() { + // 38;3 is not a recognized mode (only 2 and 5) + let actions = parse("\u{1B}[38;3;100mX") + let span = firstSpan(actions) + #expect(span?.text == "X") + // foreground should remain default since mode 3 is unrecognized + #expect(span?.style.foreground == .default) + } + + @Test func truecolorWithInsufficientParams() { + // 38;2;255;128 is missing the blue component + let actions = parse("\u{1B}[38;2;255;128mX") + let span = firstSpan(actions) + #expect(span?.text == "X") + // Should not crash; foreground stays default since insufficient params + #expect(span?.style.foreground == .default) + } + + // MARK: - Unrecognized CSI final characters + + @Test func unrecognizedCSIFinalCharIsIgnored() { + // ESC[5J is a valid CSI form but 'J' (erase display) isn't handled + let actions = parse("A\u{1B}[2JB") + let spans = allSpans(actions) + let combined = spans.map(\.text).joined() + #expect(combined == "AB") + } + + // MARK: - Complex real-world sequences + + @Test func npmColoredOutput() { + // Simulate typical npm output with reset, bold, colors + let input = "\u{1B}[1m\u{1B}[32m>\u{1B}[0m dev\n next dev" + let actions = parse(input) + let spans = allSpans(actions) + #expect(spans.count >= 2) + // First span should be bold + green ">" + #expect(spans[0].style.bold == true) + #expect(spans[0].style.foreground == .standard(2)) + #expect(spans[0].text == ">") + } + + @Test func progressBarWithCR() { + // Simulate a progress bar that uses carriage return to overwrite + let input = "Progress: 50%\rProgress: 100%" + let actions = parse(input) + #expect(actions.count == 3) + if case .text(let s) = actions[0] { #expect(s.text == "Progress: 50%") } + guard case .carriageReturn = actions[1] else { + Issue.record("Expected .carriageReturn") + return + } + if case .text(let s) = actions[2] { #expect(s.text == "Progress: 100%") } + } +} diff --git a/Tests/DevtailKitTests/PersistenceTests.swift b/Tests/DevtailKitTests/PersistenceTests.swift new file mode 100644 index 0000000..3514a78 --- /dev/null +++ b/Tests/DevtailKitTests/PersistenceTests.swift @@ -0,0 +1,246 @@ +import Foundation +import Testing + +/// These tests verify the Codable round-trip behavior of the persistence model. +/// Since `SavedProcess` lives in the executable target and cannot be imported, +/// we replicate the identical Codable structures here and verify encoding/decoding +/// logic that mirrors Sources/devtail/Persistence.swift. +struct PersistenceTests { + + // MARK: - Mirror of SavedProcess (must match Sources/devtail/Persistence.swift) + + private struct SavedProcess: Codable, Equatable { + let id: UUID + var name: String + var command: String + var workingDirectory: String + var auxiliaryCommands: [SavedAuxCommand] + var wasRunning: Bool + + struct SavedAuxCommand: Codable, Equatable { + let id: UUID + var name: String + var command: String + } + } + + // MARK: - Helpers + + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private func roundTrip(_ processes: [SavedProcess]) throws -> [SavedProcess] { + let data = try encoder.encode(processes) + return try decoder.decode([SavedProcess].self, from: data) + } + + // MARK: - Round-trip tests + + @Test func saveAndLoadRoundTripPreservesData() throws { + let id = UUID() + let auxID = UUID() + let processes = [ + SavedProcess( + id: id, + name: "Web Server", + command: "npm run dev", + workingDirectory: "/Users/test/project", + auxiliaryCommands: [ + SavedProcess.SavedAuxCommand(id: auxID, name: "Tailwind", command: "npx tailwindcss --watch") + ], + wasRunning: true + ) + ] + let loaded = try roundTrip(processes) + #expect(loaded == processes) + #expect(loaded[0].id == id) + #expect(loaded[0].name == "Web Server") + #expect(loaded[0].command == "npm run dev") + #expect(loaded[0].workingDirectory == "/Users/test/project") + #expect(loaded[0].auxiliaryCommands.count == 1) + #expect(loaded[0].auxiliaryCommands[0].id == auxID) + #expect(loaded[0].auxiliaryCommands[0].name == "Tailwind") + #expect(loaded[0].wasRunning == true) + } + + @Test func emptyProcessesListRoundTrips() throws { + let processes: [SavedProcess] = [] + let loaded = try roundTrip(processes) + #expect(loaded.isEmpty) + } + + @Test func processWithMultipleAuxiliaryCommands() throws { + let processes = [ + SavedProcess( + id: UUID(), + name: "Full Stack", + command: "next dev", + workingDirectory: "~/projects/app", + auxiliaryCommands: [ + SavedProcess.SavedAuxCommand(id: UUID(), name: "CSS", command: "tailwind --watch"), + SavedProcess.SavedAuxCommand(id: UUID(), name: "TypeCheck", command: "tsc --watch"), + SavedProcess.SavedAuxCommand(id: UUID(), name: "Lint", command: "eslint --watch"), + ], + wasRunning: false + ) + ] + let loaded = try roundTrip(processes) + #expect(loaded[0].auxiliaryCommands.count == 3) + #expect(loaded[0].auxiliaryCommands[0].name == "CSS") + #expect(loaded[0].auxiliaryCommands[1].name == "TypeCheck") + #expect(loaded[0].auxiliaryCommands[2].name == "Lint") + } + + @Test func wasRunningFlagPreserved() throws { + let running = SavedProcess( + id: UUID(), name: "A", command: "a", workingDirectory: "", + auxiliaryCommands: [], wasRunning: true + ) + let stopped = SavedProcess( + id: UUID(), name: "B", command: "b", workingDirectory: "", + auxiliaryCommands: [], wasRunning: false + ) + let loaded = try roundTrip([running, stopped]) + #expect(loaded[0].wasRunning == true) + #expect(loaded[1].wasRunning == false) + } + + @Test func workingDirectoryPreserved() throws { + let processes = [ + SavedProcess( + id: UUID(), name: "Test", command: "make test", + workingDirectory: "/some/deep/path/to/project", + auxiliaryCommands: [], wasRunning: false + ) + ] + let loaded = try roundTrip(processes) + #expect(loaded[0].workingDirectory == "/some/deep/path/to/project") + } + + @Test func emptyWorkingDirectory() throws { + let processes = [ + SavedProcess( + id: UUID(), name: "Test", command: "echo hi", + workingDirectory: "", + auxiliaryCommands: [], wasRunning: false + ) + ] + let loaded = try roundTrip(processes) + #expect(loaded[0].workingDirectory == "") + } + + @Test func multipleProcessesRoundTrip() throws { + let processes = (0..<5).map { i in + SavedProcess( + id: UUID(), + name: "Process \(i)", + command: "cmd \(i)", + workingDirectory: "/path/\(i)", + auxiliaryCommands: [], + wasRunning: i % 2 == 0 + ) + } + let loaded = try roundTrip(processes) + #expect(loaded.count == 5) + for i in 0..<5 { + #expect(loaded[i].name == "Process \(i)") + #expect(loaded[i].command == "cmd \(i)") + #expect(loaded[i].wasRunning == (i % 2 == 0)) + } + } + + @Test func uuidPreservedExactly() throws { + let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")! + let processes = [ + SavedProcess( + id: id, name: "Test", command: "test", + workingDirectory: "", auxiliaryCommands: [], wasRunning: false + ) + ] + let loaded = try roundTrip(processes) + #expect(loaded[0].id == id) + } + + // MARK: - UserDefaults-style edge cases + + @Test func malformedJSONReturnsEmptyArray() { + let badData = "not valid json".data(using: .utf8)! + let result = try? JSONDecoder().decode([SavedProcess].self, from: badData) + #expect(result == nil) + } + + @Test func missingFieldsFailToDecode() { + // JSON with missing required field "command" + let json = """ + [{"id":"12345678-1234-1234-1234-123456789ABC","name":"Test","workingDirectory":"","auxiliaryCommands":[],"wasRunning":false}] + """ + let data = json.data(using: .utf8)! + let result = try? JSONDecoder().decode([SavedProcess].self, from: data) + #expect(result == nil) + } + + @Test func emptyJSONArrayDecodesToEmpty() throws { + let data = "[]".data(using: .utf8)! + let result = try JSONDecoder().decode([SavedProcess].self, from: data) + #expect(result.isEmpty) + } + + @Test func specialCharactersInCommandPreserved() throws { + let processes = [ + SavedProcess( + id: UUID(), name: "Special", + command: "echo 'hello world' && cat /dev/null | grep -E \"^$\" > /tmp/out", + workingDirectory: "/path/with spaces/and-dashes", + auxiliaryCommands: [], wasRunning: false + ) + ] + let loaded = try roundTrip(processes) + #expect(loaded[0].command == processes[0].command) + #expect(loaded[0].workingDirectory == processes[0].workingDirectory) + } + + @Test func unicodeInNamesPreserved() throws { + let processes = [ + SavedProcess( + id: UUID(), name: "Server (production)", + command: "echo 'Hola Mundo'", + workingDirectory: "", + auxiliaryCommands: [ + SavedProcess.SavedAuxCommand(id: UUID(), name: "Worker", command: "rake jobs:work") + ], + wasRunning: false + ) + ] + let loaded = try roundTrip(processes) + #expect(loaded[0].name == "Server (production)") + #expect(loaded[0].auxiliaryCommands[0].name == "Worker") + } + + @Test func processWithNoAuxiliaryCommandsRoundTrips() throws { + let processes = [ + SavedProcess( + id: UUID(), name: "Simple", command: "ls", + workingDirectory: "", auxiliaryCommands: [], wasRunning: true + ) + ] + let loaded = try roundTrip(processes) + #expect(loaded[0].auxiliaryCommands.isEmpty) + } + + @Test func largeNumberOfProcesses() throws { + let processes = (0..<100).map { i in + SavedProcess( + id: UUID(), + name: "Process \(i)", + command: "cmd \(i)", + workingDirectory: "/path/\(i)", + auxiliaryCommands: [ + SavedProcess.SavedAuxCommand(id: UUID(), name: "Aux \(i)", command: "aux \(i)") + ], + wasRunning: false + ) + } + let loaded = try roundTrip(processes) + #expect(loaded.count == 100) + } +} diff --git a/Tests/DevtailKitTests/TerminalBufferTests.swift b/Tests/DevtailKitTests/TerminalBufferTests.swift new file mode 100644 index 0000000..e2a0692 --- /dev/null +++ b/Tests/DevtailKitTests/TerminalBufferTests.swift @@ -0,0 +1,331 @@ +import Testing + +@testable import DevtailKit + +@MainActor +struct TerminalBufferTests { + + // MARK: - Initial state + + @Test func initialStateHasOneEmptyLine() { + let buffer = TerminalBuffer() + #expect(buffer.lines.count == 1) + #expect(buffer.lines[0].isEmpty) + } + + @Test func hasContentIsFalseInitially() { + let buffer = TerminalBuffer() + #expect(buffer.hasContent == false) + } + + @Test func initialVersionIsZero() { + let buffer = TerminalBuffer() + #expect(buffer.version == 0) + } + + // MARK: - Appending text + + @Test func appendPlainTextMakesHasContentTrue() { + let buffer = TerminalBuffer() + buffer.append("hello") + #expect(buffer.hasContent == true) + } + + @Test func appendPlainTextAddsToCurrentLine() { + let buffer = TerminalBuffer() + buffer.append("hello") + #expect(buffer.lines.count == 1) + #expect(buffer.lines[0].plainText == "hello") + } + + @Test func appendMultipleTimesAccumulatesContent() { + let buffer = TerminalBuffer() + buffer.append("hello ") + buffer.append("world") + #expect(buffer.lines.count == 1) + #expect(buffer.lines[0].plainText == "hello world") + } + + // MARK: - Newline + + @Test func newlineIncrementsCursorAndAddsNewLine() { + let buffer = TerminalBuffer() + buffer.append("line1\nline2") + #expect(buffer.lines.count == 2) + #expect(buffer.lines[0].plainText == "line1") + #expect(buffer.lines[1].plainText == "line2") + } + + @Test func multipleNewlinesCreateMultipleLines() { + let buffer = TerminalBuffer() + buffer.append("a\nb\nc\n") + // "a", "b", "c", and an empty line after trailing newline + #expect(buffer.lines.count == 4) + #expect(buffer.lines[0].plainText == "a") + #expect(buffer.lines[1].plainText == "b") + #expect(buffer.lines[2].plainText == "c") + #expect(buffer.lines[3].isEmpty) + } + + @Test func newlineAcrossMultipleAppends() { + let buffer = TerminalBuffer() + buffer.append("line1\n") + buffer.append("line2") + #expect(buffer.lines.count == 2) + #expect(buffer.lines[0].plainText == "line1") + #expect(buffer.lines[1].plainText == "line2") + } + + // MARK: - Carriage return + + @Test func carriageReturnClearsCurrentLineSpans() { + let buffer = TerminalBuffer() + buffer.append("old text\rnew text") + #expect(buffer.lines.count == 1) + #expect(buffer.lines[0].plainText == "new text") + } + + @Test func carriageReturnOnEmptyLine() { + let buffer = TerminalBuffer() + buffer.append("\rtext") + #expect(buffer.lines[0].plainText == "text") + } + + // MARK: - Erase line + + @Test func eraseLineClearsCurrentLine() { + let buffer = TerminalBuffer() + buffer.append("some content\u{1B}[2K") + #expect(buffer.lines[0].isEmpty) + } + + @Test func eraseToEndOfLineClearsCurrentLine() { + let buffer = TerminalBuffer() + buffer.append("some content\u{1B}[K") + #expect(buffer.lines[0].isEmpty) + } + + @Test func eraseLineAfterNewlineOnlyAffectsCurrentLine() { + let buffer = TerminalBuffer() + buffer.append("line1\nline2\u{1B}[2K") + #expect(buffer.lines[0].plainText == "line1") + #expect(buffer.lines[1].isEmpty) + } + + // MARK: - Cursor up + + @Test func cursorUpMovesBackAndClearsTargetLine() { + let buffer = TerminalBuffer() + buffer.append("line1\nline2\n\u{1B}[2Areplaced") + // Cursor was at row 2, moved up by 2 to row 0, cleared it + #expect(buffer.lines[0].plainText == "replaced") + } + + @Test func cursorUpDoesntGoBelowZero() { + let buffer = TerminalBuffer() + buffer.append("only line\u{1B}[99Astill here") + // Cursor was at 0, up 99 clamps to 0 + #expect(buffer.lines[0].plainText == "still here") + } + + @Test func cursorUpBy1() { + let buffer = TerminalBuffer() + buffer.append("line1\nline2\u{1B}[Areplaced") + // From row 1, move up 1 to row 0 + #expect(buffer.lines[0].plainText == "replaced") + } + + // MARK: - Version tracking + + @Test func versionIncrementsOnEveryAppend() { + let buffer = TerminalBuffer() + let v0 = buffer.version + buffer.append("a") + let v1 = buffer.version + buffer.append("b") + let v2 = buffer.version + #expect(v1 == v0 + 1) + #expect(v2 == v1 + 1) + } + + @Test func versionIncrementsOnClear() { + let buffer = TerminalBuffer() + buffer.append("stuff") + let vBefore = buffer.version + buffer.clear() + #expect(buffer.version == vBefore + 1) + } + + // MARK: - Clear + + @Test func clearResetsToInitialState() { + let buffer = TerminalBuffer() + buffer.append("line1\nline2\nline3") + buffer.clear() + #expect(buffer.lines.count == 1) + #expect(buffer.lines[0].isEmpty) + #expect(buffer.hasContent == false) + } + + @Test func clearResetsParser() { + let buffer = TerminalBuffer() + // Set a style before clear + buffer.append("\u{1B}[31mred text") + buffer.clear() + // After clear, parser should be reset so new text has default style + buffer.append("plain") + #expect(buffer.lines[0].spans[0].style == ANSIStyle()) + } + + // MARK: - Buffer trimming (maxLines) + + @Test func bufferTrimsWhenExceedingMaxLines() { + let buffer = TerminalBuffer(maxLines: 5) + // Append enough lines to exceed maxLines + buffer.append("1\n2\n3\n4\n5\n6\n7") + #expect(buffer.lines.count <= 5) + } + + @Test func trimmedBufferKeepsLatestLines() { + let buffer = TerminalBuffer(maxLines: 3) + buffer.append("a\nb\nc\nd\ne") + // Should only keep the last 3 lines + #expect(buffer.lines.count == 3) + let texts = buffer.lines.map(\.plainText) + #expect(texts.contains("e")) + } + + @Test func cursorAdjustsAfterTrim() { + let buffer = TerminalBuffer(maxLines: 3) + buffer.append("1\n2\n3\n4\n5") + // After trimming, cursor should still be valid + // Appending more text should work without crash + buffer.append(" more") + #expect(buffer.lines.last?.plainText.contains("more") == true) + } + + @Test func trimWithExactMaxLinesDoesNotTrim() { + let buffer = TerminalBuffer(maxLines: 3) + buffer.append("a\nb\nc") + #expect(buffer.lines.count == 3) + #expect(buffer.lines[0].plainText == "a") + #expect(buffer.lines[1].plainText == "b") + #expect(buffer.lines[2].plainText == "c") + } + + // MARK: - ANSI colors flow through parser + + @Test func ansiColorsFlowThroughCorrectly() { + let buffer = TerminalBuffer() + buffer.append("\u{1B}[31mRed\u{1B}[0m Normal") + let spans = buffer.lines[0].spans + #expect(spans.count == 2) + #expect(spans[0].style.foreground == .standard(1)) + #expect(spans[0].text == "Red") + #expect(spans[1].style == ANSIStyle()) + #expect(spans[1].text == " Normal") + } + + @Test func boldAndColorCombined() { + let buffer = TerminalBuffer() + buffer.append("\u{1B}[1;34mBoldBlue") + let spans = buffer.lines[0].spans + #expect(spans[0].style.bold == true) + #expect(spans[0].style.foreground == .standard(4)) + } + + // MARK: - Line IDs + + @Test func lineIDsAreUniqueAndIncrementing() { + let buffer = TerminalBuffer() + buffer.append("line1\nline2\nline3") + let ids = buffer.lines.map(\.id) + // All IDs should be unique + #expect(Set(ids).count == ids.count) + // IDs should be incrementing + for i in 1.. ids[i - 1]) + } + } + + @Test func lineIDsIncrementAcrossClears() { + let buffer = TerminalBuffer() + let firstID = buffer.lines[0].id + buffer.clear() + let secondID = buffer.lines[0].id + #expect(secondID > firstID) + } + + // MARK: - TerminalLine properties + + @Test func plainTextJoinsSpans() { + let line = TerminalLine( + id: 0, + spans: [ + StyledSpan(text: "hello ", style: ANSIStyle()), + StyledSpan(text: "world", style: ANSIStyle()), + ] + ) + #expect(line.plainText == "hello world") + } + + @Test func isEmptyWithNoSpans() { + let line = TerminalLine(id: 0, spans: []) + #expect(line.isEmpty == true) + } + + @Test func isEmptyWithEmptySpans() { + let line = TerminalLine( + id: 0, + spans: [ + StyledSpan(text: "", style: ANSIStyle()) + ] + ) + #expect(line.isEmpty == true) + } + + @Test func isEmptyWithContent() { + let line = TerminalLine( + id: 0, + spans: [ + StyledSpan(text: "x", style: ANSIStyle()) + ] + ) + #expect(line.isEmpty == false) + } + + @Test func plainTextOnEmptyLine() { + let line = TerminalLine(id: 0) + #expect(line.plainText == "") + } + + // MARK: - Edge cases + + @Test func appendEmptyString() { + let buffer = TerminalBuffer() + let vBefore = buffer.version + buffer.append("") + // Version still increments (append always increments) + #expect(buffer.version == vBefore + 1) + #expect(buffer.lines.count == 1) + #expect(buffer.hasContent == false) + } + + @Test func rapidAppends() { + let buffer = TerminalBuffer() + for i in 0..<100 { + buffer.append("line\(i)\n") + } + // Should have 101 lines (100 content lines + 1 trailing empty) + #expect(buffer.lines.count == 101) + #expect(buffer.lines[0].plainText == "line0") + #expect(buffer.lines[99].plainText == "line99") + } + + @Test func maxLinesOf1() { + let buffer = TerminalBuffer(maxLines: 1) + buffer.append("a\nb\nc") + #expect(buffer.lines.count == 1) + #expect(buffer.lines[0].plainText == "c") + } +} diff --git a/web/src/App.css b/web/src/App.css index 38aaf3c..5ddcabb 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,4 +1,3 @@ -/* ── Grain texture overlay ── */ .grain { position: fixed; inset: 0; @@ -10,7 +9,6 @@ background-size: 256px 256px; } -/* ── Hero load animations ── */ @keyframes fadeInUp { from { opacity: 0; @@ -45,7 +43,6 @@ animation-delay: 0.55s; } -/* ── Scroll reveal ── */ .scroll-reveal { opacity: 0; transform: translateY(20px); @@ -59,7 +56,6 @@ transform: translateY(0); } -/* ── CTA link (primary: accent text + underline) ── */ .cta-link { position: relative; display: inline-flex; @@ -91,7 +87,6 @@ transform: translateY(1px); } -/* ── Ghost link (nav secondary) ── */ .ghost-link { position: relative; color: var(--color-muted-foreground); @@ -121,7 +116,6 @@ transform: scaleX(1); } -/* ── Feature card ── */ .feature-card { position: relative; overflow: hidden; diff --git a/web/src/App.tsx b/web/src/App.tsx index b3c12fe..79e5613 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -66,7 +66,6 @@ function App() {