diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c996440a4..5a2a74dc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: - name: Install unzip run: apt-get update && apt-get install -y unzip - name: Run tests - run: cp .env.testing.template .env.testing && make test + run: cp .env.testing.template .env.testing && make test-plain env: COLLECTION_SIGNING_PRIVATE_KEY: ${{ secrets.COLLECTION_SIGNING_PRIVATE_KEY }} DATABASE_HOST: postgres diff --git a/Makefile b/Makefile index d76fa3f8b..88217333f 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,9 @@ build: run: swift run +test-plain: + swift test --disable-automatic-resolution --sanitize=thread --no-parallel + test: xcbeautify set -o pipefail \ && swift test --disable-automatic-resolution --sanitize=thread \ diff --git a/Sources/App/Commands/Analyze.swift b/Sources/App/Commands/Analyze.swift index 950ebd108..39b449954 100644 --- a/Sources/App/Commands/Analyze.swift +++ b/Sources/App/Commands/Analyze.swift @@ -180,7 +180,7 @@ extension Analyze { // 2024-10-05 sas: We need to explicitly weave dependencies into the `transaction` closure, because escaping closures strip them. // https://github.com/pointfreeco/swift-dependencies/discussions/283#discussioncomment-10846172 // This might not be needed in Vapor 5 / FluentKit 2 - // TODO: verify this is still needed once we upgrade to Vapor 5 / FluentKit 2 + // TODO: verify if this is still needed once we upgrade to Vapor 5 / FluentKit 2 try await withEscapedDependencies { dependencies in try await database.transaction { tx in try await dependencies.yield { diff --git a/Tests/AppTests/AnalyzeErrorTests.swift b/Tests/AppTests/AnalyzeErrorTests.swift index 07eacf3da..3ef41da40 100644 --- a/Tests/AppTests/AnalyzeErrorTests.swift +++ b/Tests/AppTests/AnalyzeErrorTests.swift @@ -12,13 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import XCTest - @testable import App import Dependencies import Fluent import ShellOut +import Testing +import Vapor // Test analysis error handling. @@ -28,34 +28,17 @@ import ShellOut // // We analyze two packages where the first package is set up to encounter // various error states and ensure the second package is successfully processed. -final class AnalyzeErrorTests: AppTestCase { +@Suite struct AnalyzeErrorTests { let badPackageID: Package.Id = .id0 let goodPackageID: Package.Id = .id1 + let capturingLogger = CapturingLogger() let socialPosts = LockIsolated<[String]>([]) - static let defaultShellRun: @Sendable (ShellOutCommand, String) throws -> String = { @Sendable cmd, path in - switch cmd { - case .swiftDumpPackage where path.hasSuffix("foo-1"): - return packageSwift1 - - case .swiftDumpPackage where path.hasSuffix("foo-2"): - return packageSwift2 - - default: - return "" - } - } - - struct SetupError: Error { } struct SimulatedError: Error { } - override func setUp() async throws { - try await super.setUp() - - socialPosts.setValue([]) - + func setup(_ app: Application) async throws { let pkgs = [ Package(id: badPackageID, url: "https://github.com/foo/1".url, @@ -74,166 +57,155 @@ final class AnalyzeErrorTests: AppTestCase { ].save(on: app.db) } - override func invokeTest() { - withDependencies { - $0.date.now = .t0 - $0.environment.allowSocialPosts = { true } - $0.git.commitCount = { @Sendable _ in 1 } - $0.git.firstCommitDate = { @Sendable _ in .t0 } - $0.git.getTags = { @Sendable checkoutDir in - if checkoutDir.hasSuffix("foo-1") { return [] } - if checkoutDir.hasSuffix("foo-2") { return [.tag(1, 2, 3)] } - throw SetupError() - } - $0.git.hasBranch = { @Sendable _, _ in true } - $0.git.lastCommitDate = { @Sendable _ in .t1 } - $0.git.revisionInfo = { @Sendable ref, checkoutDir in - if checkoutDir.hasSuffix("foo-1") { return .init(commit: "commit \(ref)", date: .t1) } - if checkoutDir.hasSuffix("foo-2") { return .init(commit: "commit \(ref)", date: .t1) } - throw SetupError() - } - $0.git.shortlog = { @Sendable _ in - """ - 1000\tPerson 1 - 871\tPerson 2 - 703\tPerson 3 - 360\tPerson 4 - 108\tPerson 5 - """ - } - $0.httpClient.mastodonPost = { @Sendable [socialPosts = self.socialPosts] message in - socialPosts.withValue { $0.append(message) } + @Test func analyze_refreshCheckout_failed() async throws { + try await withApp(setup, defaultDependencies, capturingLogger) { app in + try await withDependencies { + $0.environment.loadSPIManifest = { _ in nil } + $0.fileManager.fileExists = { @Sendable _ in true } + $0.shell.run = { @Sendable cmd, path in + switch cmd { + case _ where cmd.description.contains("git clone https://github.com/foo/1"): + throw SimulatedError() + + case .gitFetchAndPruneTags where path.hasSuffix("foo-1"): + throw SimulatedError() + + default: + return try defaultShellRun(command: cmd, path: path) + } + } + } operation: { + // MUT + try await Analyze.analyze(client: app.client, database: app.db, mode: .limit(10)) + + // validate + try await defaultValidation(app) + try capturingLogger.logs.withValue { logs in + #expect(logs.count == 2) + let error = try logs.last.unwrap() + #expect(error.message.contains("refreshCheckout failed"), "was: \(error.message)") + } } - $0.shell.run = Self.defaultShellRun - } operation: { - super.invokeTest() } } - func test_analyze_refreshCheckout_failed() async throws { - try await withDependencies { - $0.environment.loadSPIManifest = { _ in nil } - $0.fileManager.fileExists = { @Sendable _ in true } - $0.shell.run = { @Sendable cmd, path in - switch cmd { - case _ where cmd.description.contains("git clone https://github.com/foo/1"): - throw SimulatedError() - - case .gitFetchAndPruneTags where path.hasSuffix("foo-1"): - throw SimulatedError() - - default: - return try Self.defaultShellRun(cmd, path) + @Test func analyze_updateRepository_invalidPackageCachePath() async throws { + try await withApp(setup, defaultDependencies, capturingLogger) { app in + try await withDependencies { + $0.environment.loadSPIManifest = { _ in nil } + $0.fileManager.fileExists = { @Sendable _ in true } + } operation: { + // setup + let pkg = try await Package.find(badPackageID, on: app.db).unwrap() + // This may look weird but its currently the only way to actually create an + // invalid package cache path - we need to mess up the package url. + pkg.url = "foo/1" + #expect(pkg.cacheDirectoryName == nil) + try await pkg.save(on: app.db) + + // MUT + try await Analyze.analyze(client: app.client, database: app.db, mode: .limit(10)) + + // validate + try await defaultValidation(app) + try capturingLogger.logs.withValue { logs in + #expect(logs.count == 2) + let error = try logs.last.unwrap() + #expect(error.message.contains( "AppError.invalidPackageCachePath"), "was: \(error.message)") } } - } operation: { - // MUT - try await Analyze.analyze(client: app.client, database: app.db, mode: .limit(10)) - - // validate - try await defaultValidation() - try logger.logs.withValue { logs in - XCTAssertEqual(logs.count, 2) - let error = try logs.last.unwrap() - XCTAssertTrue(error.message.contains("refreshCheckout failed"), "was: \(error.message)") - } } } - func test_analyze_updateRepository_invalidPackageCachePath() async throws { - try await withDependencies { - $0.environment.loadSPIManifest = { _ in nil } - $0.fileManager.fileExists = { @Sendable _ in true } - } operation: { - // setup - let pkg = try await Package.find(badPackageID, on: app.db).unwrap() - // This may look weird but its currently the only way to actually create an - // invalid package cache path - we need to mess up the package url. - pkg.url = "foo/1" - XCTAssertNil(pkg.cacheDirectoryName) - try await pkg.save(on: app.db) - - // MUT - try await Analyze.analyze(client: app.client, database: app.db, mode: .limit(10)) - - // validate - try await defaultValidation() - try logger.logs.withValue { logs in - XCTAssertEqual(logs.count, 2) - let error = try logs.last.unwrap() - XCTAssertTrue(error.message.contains( "AppError.invalidPackageCachePath"), "was: \(error.message)") + @Test func analyze_getPackageInfo_gitCheckout_error() async throws { + try await withApp(setup, defaultDependencies, capturingLogger) { app in + try await withDependencies { + $0.environment.loadSPIManifest = { _ in nil } + $0.fileManager.fileExists = { @Sendable _ in true } + $0.shell.run = { @Sendable cmd, path in + switch cmd { + case .gitCheckout(branch: "main", quiet: true) where path.hasSuffix("foo-1"): + throw SimulatedError() + + default: + return try defaultShellRun(command: cmd, path: path) + } + } + } operation: { + // MUT + try await Analyze.analyze(client: app.client, database: app.db, mode: .limit(10)) + + // validate + try await defaultValidation(app) + try capturingLogger.logs.withValue { logs in + #expect(logs.count == 2) + let error = try logs.last.unwrap() + #expect(error.message.contains("AppError.noValidVersions"), "was: \(error.message)") + } } } } - func test_analyze_getPackageInfo_gitCheckout_error() async throws { - try await withDependencies { - $0.environment.loadSPIManifest = { _ in nil } - $0.fileManager.fileExists = { @Sendable _ in true } - $0.shell.run = { @Sendable cmd, path in - switch cmd { - case .gitCheckout(branch: "main", quiet: true) where path.hasSuffix("foo-1"): - throw SimulatedError() - - default: - return try Self.defaultShellRun(cmd, path) + @Test func analyze_dumpPackage_missing_manifest() async throws { + try await withApp(setup, defaultDependencies, capturingLogger) { app in + try await withDependencies { + $0.environment.loadSPIManifest = { _ in nil } + $0.fileManager.fileExists = { @Sendable path in + if path.hasSuffix("github.com-foo-1/Package.swift") { + return false + } + return true + } + } operation: { + // MUT + try await Analyze.analyze(client: app.client, + database: app.db, + mode: .limit(10)) + + // validate + try await defaultValidation(app) + try capturingLogger.logs.withValue { logs in + #expect(logs.count == 2) + let error = try logs.last.unwrap() + #expect(error.message.contains("AppError.noValidVersions"), "was: \(error.message)") } - } - } operation: { - // MUT - try await Analyze.analyze(client: app.client, database: app.db, mode: .limit(10)) - - // validate - try await defaultValidation() - try logger.logs.withValue { logs in - XCTAssertEqual(logs.count, 2) - let error = try logs.last.unwrap() - XCTAssertTrue(error.message.contains("AppError.noValidVersions"), "was: \(error.message)") } } } - func test_analyze_dumpPackage_missing_manifest() async throws { - try await withDependencies { - $0.environment.loadSPIManifest = { _ in nil } - $0.fileManager.fileExists = { @Sendable path in - if path.hasSuffix("github.com-foo-1/Package.swift") { - return false - } - return true - } - } operation: { - // MUT - try await Analyze.analyze(client: app.client, - database: app.db, - mode: .limit(10)) - - // validate - try await defaultValidation() - try logger.logs.withValue { logs in - XCTAssertEqual(logs.count, 2) - let error = try logs.last.unwrap() - XCTAssertTrue(error.message.contains("AppError.noValidVersions"), "was: \(error.message)") - } +} + + +extension AnalyzeErrorTests { +#if compiler(>=6.1) +#warning("Move this into a trait on @Test") + // See https://forums.swift.org/t/converting-xctest-invoketest-to-swift-testing/77692/4 for details +#endif + var defaultDependencies: (inout DependencyValues) async throws -> Void { + { + $0.date.now = .t0 + $0.environment.allowSocialPosts = { true } + $0.git = .analyzeErrorTestsMock + $0.httpClient.mastodonPost = { @Sendable msg in socialPosts.withValue { $0.append(msg) } } + $0.shell.run = defaultShellRun(command:path:) } } - } extension AnalyzeErrorTests { - func defaultValidation() async throws { + func defaultValidation(_ app: Application) async throws { let versions = try await Version.query(on: app.db) .filter(\.$package.$id == goodPackageID) .all() - XCTAssertEqual(versions.count, 2) - XCTAssertEqual(versions.filter(\.isBranch).first?.latest, .defaultBranch) - XCTAssertEqual(versions.filter(\.isTag).first?.latest, .release) - socialPosts.withValue { tweets in - XCTAssertEqual(tweets, [ + #expect(versions.count == 2) + #expect(versions.filter(\.isBranch).first?.latest == .defaultBranch) + #expect(versions.filter(\.isTag).first?.latest == .release) + socialPosts.withValue { posts in + #expect(posts == [ """ ⬆️ foo just released foo-2 v1.2.3 - + http://localhost:8080/foo/2#releases """ ]) @@ -242,7 +214,53 @@ extension AnalyzeErrorTests { } -let packageSwift1 = #""" +private func defaultShellRun(command: ShellOutCommand, path: String) throws -> String { + switch command { + case .swiftDumpPackage where path.hasSuffix("foo-1"): + return packageSwift1 + + case .swiftDumpPackage where path.hasSuffix("foo-2"): + return packageSwift2 + + default: + return "" + } +} + + +private extension GitClient { + struct SetupError: Error { } + static var analyzeErrorTestsMock: Self { + .init( + commitCount: { _ in 1 }, + firstCommitDate: { _ in .t0 }, + getTags: { checkoutDir in + if checkoutDir.hasSuffix("foo-1") { return [] } + if checkoutDir.hasSuffix("foo-2") { return [.tag(1, 2, 3)] } + throw SetupError() + }, + hasBranch: { _, _ in true }, + lastCommitDate: { _ in .t1 }, + revisionInfo: { ref, checkoutDir in + if checkoutDir.hasSuffix("foo-1") { return .init(commit: "commit \(ref)", date: .t1) } + if checkoutDir.hasSuffix("foo-2") { return .init(commit: "commit \(ref)", date: .t1) } + throw SetupError() + }, + shortlog: { _ in + """ + 1000\tPerson 1 + 871\tPerson 2 + 703\tPerson 3 + 360\tPerson 4 + 108\tPerson 5 + """ + } + ) + } +} + + +private let packageSwift1 = #""" { "name": "foo-1", "products": [ @@ -258,7 +276,7 @@ let packageSwift1 = #""" } """# -let packageSwift2 = #""" +private let packageSwift2 = #""" { "name": "foo-2", "products": [ @@ -273,3 +291,4 @@ let packageSwift2 = #""" "targets": [{"name": "t2", "type": "regular"}] } """# + diff --git a/Tests/AppTests/Helpers/CapturingLogger.swift b/Tests/AppTests/Helpers/CapturingLogger.swift index a8671086a..73050db5b 100644 --- a/Tests/AppTests/Helpers/CapturingLogger.swift +++ b/Tests/AppTests/Helpers/CapturingLogger.swift @@ -22,6 +22,7 @@ struct CapturingLogger: LogHandler { } var logs = QueueIsolated<[LogEntry]>([]) + var logLevel: Logger.Level = .warning func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt) { logs.withValue { logs in @@ -39,8 +40,7 @@ struct CapturingLogger: LogHandler { set { } } - var logLevel: Logger.Level { - get { return .warning } - set { } + func reset() { + logs.withValue { $0 = [] } } } diff --git a/Tests/AppTests/Helpers/TestSupport.swift b/Tests/AppTests/Helpers/TestSupport.swift new file mode 100644 index 000000000..cd017f8d0 --- /dev/null +++ b/Tests/AppTests/Helpers/TestSupport.swift @@ -0,0 +1,44 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import App + +import Dependencies +import Vapor + + +func withApp(_ setup: (Application) async throws -> Void = { _ in }, + _ updateValuesForOperation: (inout DependencyValues) async throws -> Void = { _ in }, + _ logHandler: LogHandler? = nil, + environment: Environment = .testing, + _ test: (Application) async throws -> Void) async throws { + try await AppTestCase.setupDb(environment) + let app = try await AppTestCase.setupApp(environment) + + return try await run { + try await setup(app) + try await withDependencies(updateValuesForOperation) { + let logger = logHandler.map { handler in Logger(label: "test", factory: { _ in handler }) } + try await withDependencies { + if let logger { + $0.logger.set(to: logger) + } + } operation: { + try await test(app) + } + } + } defer: { + try await app.asyncShutdown() + } +}