diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml new file mode 100644 index 0000000..af3f769 --- /dev/null +++ b/.github/workflows/swift-tests.yml @@ -0,0 +1,39 @@ +name: Swift Tests + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + test: + name: Swift ${{ matrix.swift }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + swift: ['6.2'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: ${{ matrix.swift }} + + - name: Show Swift version + run: swift --version + + - name: Resolve dependencies + run: swift package resolve + + - name: Build + run: swift build + + - name: Test + run: swift test diff --git a/Package.swift b/Package.swift index dac325d..911f46b 100644 --- a/Package.swift +++ b/Package.swift @@ -22,5 +22,9 @@ let package = Package( .product(name: "FuzzyTUI", package: "tui-fuzzy-finder"), ] ), + .testTarget( + name: "sproutTests", + dependencies: ["sprout"] + ), ] ) diff --git a/Sources/sprout/Commands/Prune.swift b/Sources/sprout/Commands/Prune.swift index d2a6daf..aa5e379 100644 --- a/Sources/sprout/Commands/Prune.swift +++ b/Sources/sprout/Commands/Prune.swift @@ -1,5 +1,4 @@ import ArgumentParser -import Darwin import Foundation import FuzzyTUI @@ -133,7 +132,6 @@ struct Prune: AsyncParsableCommand { print(" - \(wt.branch)") } print("\nConfirm? [y/N]: ", terminator: "") - fflush(stdout) guard let confirm = readLine()?.lowercased(), confirm == "y" || confirm == "yes" else { print("Cancelled.") return @@ -152,7 +150,6 @@ struct Prune: AsyncParsableCommand { } } else { print("Removing \(wt.branch)...", terminator: " ") - fflush(stdout) do { // Capture worktree path before removal for hook let worktreePath = wt.path diff --git a/Sources/sprout/Models/InputSource.swift b/Sources/sprout/Models/InputSource.swift index c53238f..1983e92 100644 --- a/Sources/sprout/Models/InputSource.swift +++ b/Sources/sprout/Models/InputSource.swift @@ -1,7 +1,7 @@ import Foundation /// Represents the detected source type for the input -enum InputSource: CustomStringConvertible { +enum InputSource: CustomStringConvertible, Equatable { /// Jira ticket (e.g., "IOS-1234") case jira(String) diff --git a/Sources/sprout/Services/GitHubClient.swift b/Sources/sprout/Services/GitHubClient.swift index 87b6198..172aaa0 100644 --- a/Sources/sprout/Services/GitHubClient.swift +++ b/Sources/sprout/Services/GitHubClient.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Client for fetching issues from GitHub API struct GitHubClient { @@ -95,7 +98,7 @@ struct GitHubClient { case 404: throw SourceError.ticketNotFound(errorId) default: - throw SourceError.networkError(URLError(.init(rawValue: httpResponse.statusCode))) + throw SourceError.networkError(URLError(.badServerResponse)) } } } diff --git a/Sources/sprout/Services/GitService.swift b/Sources/sprout/Services/GitService.swift index a9a1e7b..c251893 100644 --- a/Sources/sprout/Services/GitService.swift +++ b/Sources/sprout/Services/GitService.swift @@ -2,11 +2,18 @@ import Foundation /// Git operations using Foundation Process struct GitService { + let workingDirectoryURL: URL? + + init(workingDirectoryURL: URL? = nil) { + self.workingDirectoryURL = workingDirectoryURL + } + /// Run a git command and return stdout private func run(_ arguments: [String]) throws -> String { let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = arguments + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git"] + arguments + process.currentDirectoryURL = workingDirectoryURL let stdout = Pipe() process.standardOutput = stdout @@ -22,8 +29,9 @@ struct GitService { /// Run a git command, capturing stderr too private func runWithStderr(_ arguments: [String]) throws -> (stdout: String, stderr: String, success: Bool) { let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = arguments + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git"] + arguments + process.currentDirectoryURL = workingDirectoryURL let stdout = Pipe() let stderr = Pipe() diff --git a/Sources/sprout/Services/JiraClient.swift b/Sources/sprout/Services/JiraClient.swift index d6a084e..e7a96b7 100644 --- a/Sources/sprout/Services/JiraClient.swift +++ b/Sources/sprout/Services/JiraClient.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Client for fetching tickets from Jira API struct JiraClient { @@ -49,7 +52,7 @@ struct JiraClient { case 404: throw SourceError.ticketNotFound(ticketId) default: - throw SourceError.networkError(URLError(.init(rawValue: httpResponse.statusCode))) + throw SourceError.networkError(URLError(.badServerResponse)) } // Parse response diff --git a/Tests/sproutTests/ConfigLoaderServiceTests.swift b/Tests/sproutTests/ConfigLoaderServiceTests.swift new file mode 100644 index 0000000..b5ae8fb --- /dev/null +++ b/Tests/sproutTests/ConfigLoaderServiceTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import sprout + +@Suite("Config loader") +struct ConfigLoaderServiceTests { + @Test("loads TOML config", .tags(.service)) + func configLoaderLoadsFile() throws { + try withTemporaryDirectory { dir in + let configFile = dir.appendingPathComponent("config.toml") + let toml = """ + [launch] + script = "echo hi" + """ + try toml.write(to: configFile, atomically: true, encoding: .utf8) + + let config = try ConfigLoader.load(from: configFile.path) + #expect(config.launch.script == "echo hi") + #expect(config.launch.resolvedPRScript == "echo hi") + } + } + + @Test("throws for missing file", .tags(.service)) + func configLoaderMissingFile() { + #expect(throws: ConfigError.self) { + try ConfigLoader.load(from: "/tmp/definitely-missing-sprout-config.toml") + } + } +} diff --git a/Tests/sproutTests/GitServiceTests.swift b/Tests/sproutTests/GitServiceTests.swift new file mode 100644 index 0000000..ee0e86e --- /dev/null +++ b/Tests/sproutTests/GitServiceTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import sprout + +@Suite("Git service") +struct GitServiceTests { + @Test("gets repo root and detects local branch", .tags(.service)) + func gitServiceRepoAndBranch() async throws { + try await withTemporaryDirectory { dir in + _ = try runProcess(["git", "init"], cwd: dir) + _ = try runProcess(["git", "config", "user.email", "tests@example.com"], cwd: dir) + _ = try runProcess(["git", "config", "user.name", "sprout-tests"], cwd: dir) + try "hello".write(to: dir.appendingPathComponent("README.md"), atomically: true, encoding: .utf8) + _ = try runProcess(["git", "add", "."], cwd: dir) + _ = try runProcess(["git", "commit", "-m", "init"], cwd: dir) + _ = try runProcess(["git", "checkout", "-b", "feature/test-branch"], cwd: dir) + + let service = GitService(workingDirectoryURL: dir) + let root = try await service.getRepoRoot() + let normalizedRoot = URL(fileURLWithPath: root).resolvingSymlinksInPath().path + let normalizedDir = dir.resolvingSymlinksInPath().path + #expect(normalizedRoot == normalizedDir) + #expect(try await service.branchExists("feature/test-branch")) + #expect(!(try await service.branchExists("missing/branch"))) + } + } + + @Test("parses GitHub remote URL formats", .tags(.service)) + func gitServiceRemoteParsing() async throws { + try await withTemporaryDirectory { dir in + _ = try runProcess(["git", "init"], cwd: dir) + + let service = GitService(workingDirectoryURL: dir) + + _ = try runProcess(["git", "remote", "add", "origin", "https://github.com/apple/swift.git"], cwd: dir) + #expect(try await service.getRemoteRepo() == "apple/swift") + + _ = try runProcess(["git", "remote", "set-url", "origin", "git@github.com:pointfreeco/swift-dependencies.git"], cwd: dir) + #expect(try await service.getRemoteRepo() == "pointfreeco/swift-dependencies") + } + } +} diff --git a/Tests/sproutTests/HooksServiceTests.swift b/Tests/sproutTests/HooksServiceTests.swift new file mode 100644 index 0000000..d711405 --- /dev/null +++ b/Tests/sproutTests/HooksServiceTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import sprout + +@Suite("Hooks service") +struct HooksServiceTests { + @Test("resolves main repo root from worktree .git file", .tags(.service)) + func hooksServiceFindMainRepoRoot() throws { + try withTemporaryDirectory { dir in + let worktree = dir.appendingPathComponent("worktree") + try FileManager.default.createDirectory(at: worktree, withIntermediateDirectories: true) + + let gitFile = worktree.appendingPathComponent(".git") + let content = "gitdir: \(dir.path)/.git/worktrees/feature-123\n" + try content.write(to: gitFile, atomically: true, encoding: .utf8) + + let hooks = HooksService() + #expect(hooks.findMainRepoRoot(from: worktree.path) == dir.path) + } + } +} diff --git a/Tests/sproutTests/InputDetectorTests.swift b/Tests/sproutTests/InputDetectorTests.swift new file mode 100644 index 0000000..21b4f96 --- /dev/null +++ b/Tests/sproutTests/InputDetectorTests.swift @@ -0,0 +1,17 @@ +import Testing +@testable import sprout + +@Suite("Input detector") +struct InputDetectorTests { + @Test("detects Jira, GitHub issue/PR, and raw prompt", .tags(.unit)) + func inputDetection() { + #expect(InputDetector.detect("IOS-123") == .jira("IOS-123")) + #expect(InputDetector.detect("#88") == .github("88", repo: nil)) + #expect(InputDetector.detect("gh:99") == .github("99", repo: nil)) + #expect(InputDetector.detect("pr:77") == .githubPR("77", repo: nil)) + #expect(InputDetector.detect("https://github.com/org/repo/issues/12") == .github("12", repo: "org/repo")) + #expect(InputDetector.detect("https://github.com/org/repo/pull/13") == .githubPR("13", repo: "org/repo")) + #expect(InputDetector.detect("https://acme.atlassian.net/browse/IOS-7") == .jira("IOS-7")) + #expect(InputDetector.detect("draft an architecture plan") == .rawPrompt("draft an architecture plan")) + } +} diff --git a/Tests/sproutTests/InterpolationTests.swift b/Tests/sproutTests/InterpolationTests.swift new file mode 100644 index 0000000..98844ae --- /dev/null +++ b/Tests/sproutTests/InterpolationTests.swift @@ -0,0 +1,12 @@ +import Testing +@testable import sprout + +@Suite("Interpolation") +struct InterpolationTests { + @Test("replaces known variables and preserves unknown", .tags(.unit)) + func interpolationBehavior() { + let template = "Hello {name}, ticket {id}, keep {unknown}." + let output = Interpolation.interpolate(template, with: ["name": "Ada", "id": "42"]) + #expect(output == "Hello Ada, ticket 42, keep {unknown}.") + } +} diff --git a/Tests/sproutTests/PromptComposerTests.swift b/Tests/sproutTests/PromptComposerTests.swift new file mode 100644 index 0000000..7903cc9 --- /dev/null +++ b/Tests/sproutTests/PromptComposerTests.swift @@ -0,0 +1,26 @@ +import Testing +@testable import sprout + +@Suite("Prompt composer") +struct PromptComposerTests { + @Test("includes prefix/body/suffix with interpolation", .tags(.unit)) + func promptComposer() { + let config = PromptConfig(prefix: "User: {user}", template: "# {title}\n\n{description}", suffix: "Path: {worktree}") + let composer = PromptComposer(config: config) + let variables = [ + "user": "sam", + "title": "Implement cache", + "description": "Add LRU cache", + "worktree": "/tmp/worktrees/cache" + ] + + let output = composer.composeContent( + context: TicketContext(ticketId: "IOS-1", title: "Implement cache", description: "Add LRU cache", slug: "implement-cache"), + variables: variables + ) + + #expect(output.contains("User: sam")) + #expect(output.contains("# Implement cache")) + #expect(output.contains("Path: /tmp/worktrees/cache")) + } +} diff --git a/Tests/sproutTests/ScriptRunnerTests.swift b/Tests/sproutTests/ScriptRunnerTests.swift new file mode 100644 index 0000000..2d50845 --- /dev/null +++ b/Tests/sproutTests/ScriptRunnerTests.swift @@ -0,0 +1,15 @@ +import Testing +@testable import sprout + +@Suite("Script runner") +struct ScriptRunnerTests { + @Test("executes success and fails on non-zero exit", .tags(.service)) + func scriptRunnerBehavior() async throws { + let runner = ScriptRunner() + try await runner.execute("echo ok", verbose: false) + + await #expect(throws: ScriptError.self) { + try await runner.execute("exit 7", verbose: false) + } + } +} diff --git a/Tests/sproutTests/SlugifyTests.swift b/Tests/sproutTests/SlugifyTests.swift new file mode 100644 index 0000000..1ef1c5f --- /dev/null +++ b/Tests/sproutTests/SlugifyTests.swift @@ -0,0 +1,18 @@ +import Testing +@testable import sprout + +@Suite("Slugify") +struct SlugifyTests { + @Test("normalizes text and punctuation", .tags(.unit)) + func slugifyBasic() { + #expect(Slugify.slugify("Fix Login_Button NOW!!!") == "fix-login-button-now") + } + + @Test("truncates long output", .tags(.unit)) + func slugifyTruncates() { + let input = String(repeating: "abc_", count: 30) + let slug = Slugify.slugify(input) + #expect(slug.count <= 50) + #expect(!slug.hasSuffix("-")) + } +} diff --git a/Tests/sproutTests/TestSupport.swift b/Tests/sproutTests/TestSupport.swift new file mode 100644 index 0000000..468a00e --- /dev/null +++ b/Tests/sproutTests/TestSupport.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing + +struct TemporaryDirectory { + let url: URL + + init(prefix: String = "sprout-tests-", function: StaticString = #function) throws { + let cleaned = String(describing: function) + .replacingOccurrences(of: "(", with: "") + .replacingOccurrences(of: ")", with: "") + .replacingOccurrences(of: ".", with: "") + .replacingOccurrences(of: ":", with: "_") + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)\(cleaned)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + self.url = dir + } + + func cleanup() { + try? FileManager.default.removeItem(at: url) + } +} + +@discardableResult +func withTemporaryDirectory( + function: StaticString = #function, + _ body: (URL) throws -> T +) throws -> T { + let temp = try TemporaryDirectory(function: function) + defer { temp.cleanup() } + return try body(temp.url) +} + +@discardableResult +func withTemporaryDirectory( + function: StaticString = #function, + _ body: (URL) async throws -> T +) async throws -> T { + let temp = try TemporaryDirectory(function: function) + defer { temp.cleanup() } + return try await body(temp.url) +} + +extension Trait where Self == ConditionTrait { + static func requires(executable tool: String) -> Self { + enabled("requires '\(tool)' to be available on PATH") { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["sh", "-lc", "command -v \"\(tool)\" >/dev/null 2>&1"] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + } +} + +@discardableResult +func runProcess(_ arguments: [String], cwd: URL? = nil) throws -> (Int32, String, String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = arguments + if let cwd { + process.currentDirectoryURL = cwd + } + + let out = Pipe() + let err = Pipe() + process.standardOutput = out + process.standardError = err + + try process.run() + process.waitUntilExit() + + let outStr = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let errStr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, outStr, errStr) +} diff --git a/Tests/sproutTests/TestTags.swift b/Tests/sproutTests/TestTags.swift new file mode 100644 index 0000000..806f770 --- /dev/null +++ b/Tests/sproutTests/TestTags.swift @@ -0,0 +1,6 @@ +import Testing + +extension Tag { + @Tag static var unit: Self + @Tag static var service: Self +}