diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 000000000..4ee8ba896 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,56 @@ +name: Run Integration Tests + +on: + workflow_dispatch: + pull_request: + paths: + - 'Samples/Tests/**' + - 'Samples/Common/Sources/IntegrationTestsHelper/**' + - '.github/workflows/integration-tests.yml' + push: + branches: + - master + schedule: + - cron: '0 0 1 * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + integration-tests: + runs-on: macos-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - platform: iOS + - platform: macOS + - platform: tvOS + - platform: watchOS + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Use Latest Stable Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install Tuist + run: | + brew tap tuist/tuist + brew install tuist@4.18.0 + + - name: Generate Project + working-directory: ./Samples + run: tuist generate --verbose --no-open + + - name: Run Build + uses: mxcl/xcodebuild@v3.0.0 + with: + action: test + workspace: "Samples/KSCrashSamples.xcworkspace" + scheme: "Sample" + platform: ${{ matrix.platform }} diff --git a/Samples/Common/Package.swift b/Samples/Common/Package.swift index d8dd2e127..987f5e16c 100644 --- a/Samples/Common/Package.swift +++ b/Samples/Common/Package.swift @@ -19,6 +19,10 @@ let package = Package( name: "CrashTriggers", targets: ["CrashTriggers"] ), + .library( + name: "IntegrationTestsHelper", + targets: ["IntegrationTestsHelper"] + ), .library( name: "SampleUI", targets: ["SampleUI"] @@ -40,11 +44,21 @@ let package = Package( .target( name: "CrashTriggers" ), + .target( + name: "IntegrationTestsHelper", + dependencies: [ + .target(name: "CrashTriggers"), + .product(name: "Recording", package: "KSCrash"), + .product(name: "Reporting", package: "KSCrash"), + .product(name: "Logging", package: "swift-log"), + ] + ), .target( name: "SampleUI", dependencies: [ .target(name: "LibraryBridge"), .target(name: "CrashTriggers"), + .target(name: "IntegrationTestsHelper"), .product(name: "Logging", package: "swift-log"), ] ) diff --git a/Samples/Common/Sources/CrashTriggers/KSCrashTriggersHelper.m b/Samples/Common/Sources/CrashTriggers/KSCrashTriggersHelper.m new file mode 100644 index 000000000..77603977e --- /dev/null +++ b/Samples/Common/Sources/CrashTriggers/KSCrashTriggersHelper.m @@ -0,0 +1,86 @@ +// +// KSCrashTriggersHelper.mm +// +// Created by Nikolay Volosatov on 2024-08-10. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "KSCrashTriggersHelper.h" +#import "KSCrashTriggersList.h" + +@implementation KSCrashTriggersHelper + ++ (NSArray *)groupIds +{ + return @[ +#define __PROCESS_GROUP(GROUP, NAME) @ #GROUP, + __ALL_GROUPS +#undef __PROCESS_GROUP + ]; +} + ++ (NSString *)nameForGroup:(NSString *)groupId +{ +#define __PROCESS_GROUP(GROUP, NAME) \ + if ([groupId isEqualToString:@ #GROUP]) { \ + return NAME; \ + } + __ALL_GROUPS +#undef __PROCESS_GROUP + return @"Unknown"; +} + ++ (NSArray *)triggersForGroup:(NSString *)groupId +{ + NSMutableArray *result = [NSMutableArray array]; +#define __PROCESS_TRIGGER(GROUP, ID, NAME) \ + if ([groupId isEqualToString:@ #GROUP]) { \ + [result addObject:TRIGGER_ID(GROUP, ID)]; \ + } + __ALL_TRIGGERS +#undef __PROCESS_TRIGGER + return result; +} + ++ (NSString *)nameForTrigger:(KSCrashTriggerId)triggerId +{ +#define __PROCESS_TRIGGER(GROUP, ID, NAME) \ + if ([triggerId isEqualToString:TRIGGER_ID(GROUP, ID)]) { \ + return NAME; \ + } + __ALL_TRIGGERS +#undef __PROCESS_TRIGGER + return @"Unknown"; +} + ++ (void)runTrigger:(KSCrashTriggerId)triggerId +{ +#define __PROCESS_TRIGGER(GROUP, ID, NAME) \ + if ([triggerId isEqualToString:TRIGGER_ID(GROUP, ID)]) { \ + [KSCrashTriggersList trigger_##GROUP##_##ID]; \ + return; \ + } + __ALL_TRIGGERS +#undef __PROCESS_TRIGGER +} + +@end diff --git a/Samples/Common/Sources/CrashTriggers/KSCrashTriggersList.mm b/Samples/Common/Sources/CrashTriggers/KSCrashTriggersList.mm new file mode 100644 index 000000000..487965607 --- /dev/null +++ b/Samples/Common/Sources/CrashTriggers/KSCrashTriggersList.mm @@ -0,0 +1,82 @@ +// +// KSCrashTriggersList.mm +// +// Created by Nikolay Volosatov on 2024-06-23. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "KSCrashTriggersList.h" + +#import +#import +#import + +@implementation KSCrashTriggersList + ++ (void)trigger_nsException_genericNSException +{ + NSException *exc = [NSException exceptionWithName:NSGenericException reason:@"Test" userInfo:@{ @"a" : @"b" }]; + [exc raise]; +} + ++ (void)trigger_nsException_nsArrayOutOfBounds +{ + NSArray *array = @[ @1, @2, @3 ]; + [array objectAtIndex:10]; // This will throw an NSRangeException +} + ++ (void)trigger_cpp_runtimeException +{ + throw std::runtime_error("C++ exception"); +} + ++ (void)trigger_mach_badAccess +{ + volatile int *ptr = (int *)0x42; + *ptr = 42; // This will cause an EXC_BAD_ACCESS (SIGSEGV) +} + ++ (void)trigger_mach_busError +{ + char *ptr = (char *)malloc(sizeof(int)); + int *intPtr = (int *)(ptr + 1); // Misaligned pointer + *intPtr = 42; // This will cause an EXC_BAD_ACCESS (SIGBUS) + free(ptr); +} + ++ (void)trigger_mach_illegalInstruction +{ + void (*funcPtr)() = (void (*)())0xDEADBEEF; + funcPtr(); // This will cause an EXC_BAD_INSTRUCTION +} + ++ (void)trigger_signal_abort +{ + abort(); // This will raise a SIGABRT signal +} + ++ (void)trigger_other_stackOverflow +{ + [self trigger_other_stackOverflow]; +} + +@end diff --git a/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersHelper.h b/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersHelper.h new file mode 100644 index 000000000..acf00fb27 --- /dev/null +++ b/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersHelper.h @@ -0,0 +1,51 @@ +// +// KSCrashTriggersHelper.h +// +// Created by Nikolay Volosatov on 2024-08-10. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import "KSCrashTriggersList.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NSString *KSCrashTriggerId NS_TYPED_ENUM NS_SWIFT_NAME(CrashTriggerId); +#define TRIGGER_ID(GROUP, ID) KSCrashTriggerId_##GROUP##_##ID +#define __PROCESS_TRIGGER(GROUP, ID, NAME) \ + static KSCrashTriggerId const TRIGGER_ID(GROUP, ID) NS_SWIFT_NAME(GROUP##_##ID) = @"trigger-" #GROUP "-" #ID; +__ALL_TRIGGERS +#undef __PROCESS_TRIGGER + +NS_SWIFT_NAME(CrashTriggersHelper) +@interface KSCrashTriggersHelper : NSObject + ++ (NSArray *)groupIds; ++ (NSString *)nameForGroup:(NSString *)groupId; + ++ (NSArray *)triggersForGroup:(NSString *)groupId; ++ (NSString *)nameForTrigger:(KSCrashTriggerId)triggerId; + ++ (void)runTrigger:(KSCrashTriggerId)triggerId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersList.h b/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersList.h new file mode 100644 index 000000000..72f8911eb --- /dev/null +++ b/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersList.h @@ -0,0 +1,57 @@ +// +// KSCrashTriggersList.h +// +// Created by Nikolay Volosatov on 2024-06-23. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +#define __ALL_GROUPS \ + __PROCESS_GROUP(nsException, @"NSException") \ + __PROCESS_GROUP(cpp, @"C++") \ + __PROCESS_GROUP(mach, @"Mach") \ + __PROCESS_GROUP(signal, @"Signal") \ + __PROCESS_GROUP(other, @"Other") + +#define __ALL_TRIGGERS \ + __PROCESS_TRIGGER(nsException, genericNSException, @"Generic NSException") \ + __PROCESS_TRIGGER(nsException, nsArrayOutOfBounds, @"NSArray out-of-bounds") \ + __PROCESS_TRIGGER(cpp, runtimeException, @"Runtime Exception") \ + __PROCESS_TRIGGER(mach, badAccess, @"EXC_BAD_ACCESS (SIGSEGV)") \ + __PROCESS_TRIGGER(mach, busError, @"EXC_BAD_ACCESS (SIGBUS)") \ + __PROCESS_TRIGGER(mach, illegalInstruction, @"EXC_BAD_INSTRUCTION") \ + __PROCESS_TRIGGER(signal, abort, @"Abort") \ + __PROCESS_TRIGGER(other, stackOverflow, @"Stack overflow") + +NS_SWIFT_NAME(CrashTriggersList) +@interface KSCrashTriggersList : NSObject + +#define __PROCESS_TRIGGER(GROUP, ID, NAME) +(void)trigger_##GROUP##_##ID; +__ALL_TRIGGERS +#undef __PROCESS_TRIGGER + +@end + +NS_ASSUME_NONNULL_END diff --git a/Samples/Common/Sources/CrashTriggers/CrashTriggers.mm b/Samples/Common/Sources/IntegrationTestsHelper/CrashTriggerConfig.swift similarity index 72% rename from Samples/Common/Sources/CrashTriggers/CrashTriggers.mm rename to Samples/Common/Sources/IntegrationTestsHelper/CrashTriggerConfig.swift index 75cdfba06..5d8e9551c 100644 --- a/Samples/Common/Sources/CrashTriggers/CrashTriggers.mm +++ b/Samples/Common/Sources/IntegrationTestsHelper/CrashTriggerConfig.swift @@ -1,7 +1,7 @@ // -// CrashTriggers.mm +// CrashTriggerConfig.swift // -// Created by Nikolay Volosatov on 2024-06-23. +// Created by Nikolay Volosatov on 2024-08-11. // // Copyright (c) 2012 Karl Stenerud. All rights reserved. // @@ -24,14 +24,22 @@ // THE SOFTWARE. // -#import "CrashTriggers.h" +import Foundation +import CrashTriggers -@implementation CrashTriggers +public struct CrashTriggerConfig: Codable { + public var triggerId: CrashTriggerId -+ (void)nsexception -{ - NSException *exc = [NSException exceptionWithName:NSGenericException reason:@"Test" userInfo:@{ @"a" : @"b" }]; - [exc raise]; + public init(triggerId: CrashTriggerId) { + self.triggerId = triggerId + } } -@end +extension CrashTriggerId: Codable { +} + +extension CrashTriggerConfig { + func crash() { + CrashTriggersHelper.runTrigger(triggerId) + } +} diff --git a/Samples/Common/Sources/CrashTriggers/include/CrashTriggers.h b/Samples/Common/Sources/IntegrationTestsHelper/InstallConfig.swift similarity index 64% rename from Samples/Common/Sources/CrashTriggers/include/CrashTriggers.h rename to Samples/Common/Sources/IntegrationTestsHelper/InstallConfig.swift index 3b6aa6887..64e79f944 100644 --- a/Samples/Common/Sources/CrashTriggers/include/CrashTriggers.h +++ b/Samples/Common/Sources/IntegrationTestsHelper/InstallConfig.swift @@ -1,7 +1,7 @@ // -// CrashTriggers.h +// InstallConfig.swift // -// Created by Nikolay Volosatov on 2024-06-23. +// Created by Nikolay Volosatov on 2024-08-11. // // Copyright (c) 2012 Karl Stenerud. All rights reserved. // @@ -24,10 +24,25 @@ // THE SOFTWARE. // -#import +import Foundation +import KSCrashRecording -@interface CrashTriggers : NSObject +public struct InstallConfig: Codable { + public var installPath: String + public var isCxaThrowEnabled: Bool? -+ (void)nsexception; + public init(installPath: String) { + self.installPath = installPath + } +} -@end +extension InstallConfig { + func install() throws { + KSCrash.setBasePath(installPath) + let config = KSCrashConfiguration() + if let isCxaThrowEnabled { + config.enableSwapCxaThrow = isCxaThrowEnabled + } + try KSCrash.shared.install(with: config) + } +} diff --git a/Samples/Common/Sources/IntegrationTestsHelper/IntegrationTestRunner.swift b/Samples/Common/Sources/IntegrationTestsHelper/IntegrationTestRunner.swift new file mode 100644 index 000000000..27386a422 --- /dev/null +++ b/Samples/Common/Sources/IntegrationTestsHelper/IntegrationTestRunner.swift @@ -0,0 +1,82 @@ +// +// IntegrationTestRunner.swift +// +// Created by Nikolay Volosatov on 2024-08-11. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +public final class IntegrationTestRunner { + + private struct Script: Codable { + var install: InstallConfig? + var crashTrigger: CrashTriggerConfig? + var report: ReportConfig? + + var delay: TimeInterval? + } + + public static let runScriptAccessabilityId = "run-integration-test" + + public static var isTestRun: Bool { + ProcessInfo.processInfo.environment[Self.envKey] != nil + } + + public static func runIfNeeded() { + guard let scriptString = ProcessInfo.processInfo.environment[Self.envKey], + let data = Data(base64Encoded: scriptString), + let script = try? JSONDecoder().decode(Script.self, from: data) + else { + return + } + + if let installConfig = script.install { + try! installConfig.install() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + (script.delay ?? 0)) { + if let crashTrigger = script.crashTrigger { + crashTrigger.crash() + } + if let report = script.report { + report.report() + } + } + } + +} + +/// API for tests +public extension IntegrationTestRunner { + static let envKey = "KSCrashIntegrationScript" + + static func script(crash: CrashTriggerConfig, install: InstallConfig? = nil, delay: TimeInterval? = nil) throws -> String { + let data = try JSONEncoder().encode(Script(install: install, crashTrigger: crash, delay: delay)) + return data.base64EncodedString() + } + + static func script(report: ReportConfig, install: InstallConfig? = nil, delay: TimeInterval? = nil) throws -> String { + let data = try JSONEncoder().encode(Script(install: install, report: report, delay: delay)) + return data.base64EncodedString() + } +} diff --git a/Samples/Common/Sources/IntegrationTestsHelper/ReportConfig.swift b/Samples/Common/Sources/IntegrationTestsHelper/ReportConfig.swift new file mode 100644 index 000000000..b53ba64e6 --- /dev/null +++ b/Samples/Common/Sources/IntegrationTestsHelper/ReportConfig.swift @@ -0,0 +1,84 @@ +// +// ReportConfig.swift +// +// Created by Nikolay Volosatov on 2024-08-11. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation +import Logging +import KSCrashRecording +import KSCrashFilters +import KSCrashSinks + +public struct ReportConfig: Codable { + public var directoryPath: String + + public init(directoryPath: String) { + self.directoryPath = directoryPath + } +} + +extension ReportConfig { + func report() { + let url = URL(fileURLWithPath: directoryPath) + KSCrash.shared.sink = CrashReportFilterPipeline(filtersArray: [ + CrashReportFilterAppleFmt(), + DirectorySink(url), + ]) + KSCrash.shared.sendAllReports() + } +} + +public class DirectorySink: NSObject, CrashReportFilter { + private static let logger = Logger(label: "DirectorySink") + + private let directoryUrl: URL + + public init(_ directoryUrl: URL) { + self.directoryUrl = directoryUrl + } + + public func filterReports(_ reports: [any CrashReport], onCompletion: (([any CrashReport]?, Bool, (any Error)?) -> Void)? = nil) { + let prefix = UUID().uuidString + for (idx, report) in reports.enumerated() { + let fileName = "\(prefix)-\(idx).ips" + let fileUrl = directoryUrl.appendingPathComponent(fileName) + + let data: Data + if let stringReport = report as? CrashReportString { + data = stringReport.value.data(using: .utf8)! + } else if let dataReport = report as? CrashReportData { + data = dataReport.value + } else { + continue + } + + do { + try data.write(to: fileUrl) + } catch { + Self.logger.error("Failed to save report: \(error)") + } + } + onCompletion?(reports, true, nil) + } +} diff --git a/Samples/Common/Sources/SampleUI/SampleView.swift b/Samples/Common/Sources/SampleUI/SampleView.swift index ccdddd22c..771b08bb2 100644 --- a/Samples/Common/Sources/SampleUI/SampleView.swift +++ b/Samples/Common/Sources/SampleUI/SampleView.swift @@ -27,6 +27,7 @@ import SwiftUI import LibraryBridge import CrashTriggers +import IntegrationTestsHelper public struct SampleView: View { public init() { } @@ -49,6 +50,6 @@ public struct SampleView: View { ) .navigationTitle("Install KSCrash") } - } + }.onAppear { IntegrationTestRunner.runIfNeeded() } } } diff --git a/Samples/Common/Sources/SampleUI/Screens/CrashView.swift b/Samples/Common/Sources/SampleUI/Screens/CrashView.swift index 83d2936c1..25e2abe6b 100644 --- a/Samples/Common/Sources/SampleUI/Screens/CrashView.swift +++ b/Samples/Common/Sources/SampleUI/Screens/CrashView.swift @@ -27,11 +27,45 @@ import SwiftUI import CrashTriggers +private typealias Helper = CrashTriggersHelper + +private struct CrashTrigger: Identifiable { + var id: CrashTriggerId + var name: String + var body: () -> Void +} + +private struct CrashGroup: Identifiable { + var id: String + var name: String + var triggers: [CrashTrigger] +} + struct CrashView: View { + private static let groups: [CrashGroup] = { + Helper.groupIds().map { groupId in + .init( + id: groupId, + name: Helper.name(forGroup: groupId), + triggers: Helper.triggers(forGroup: groupId).map { triggerId in + .init( + id: triggerId, + name: Helper.name(forTrigger: triggerId), + body: { Helper.runTrigger(triggerId) } + ) + } + ) + } + }() + var body: some View { List { - Button("NSException") { - CrashTriggers.nsexception() + ForEach(Self.groups) { group in + Section(header: Text(group.name)) { + ForEach(group.triggers) { trigger in + Button(trigger.name, action: trigger.body) + } + } } } .navigationTitle("Crash") diff --git a/Samples/Common/Sources/SampleUI/Screens/ReportingView.swift b/Samples/Common/Sources/SampleUI/Screens/ReportingView.swift index effe0c31a..abb999557 100644 --- a/Samples/Common/Sources/SampleUI/Screens/ReportingView.swift +++ b/Samples/Common/Sources/SampleUI/Screens/ReportingView.swift @@ -40,4 +40,3 @@ struct ReportingView: View { .navigationTitle("Report") } } - diff --git a/Samples/Project.swift b/Samples/Project.swift index b761b2bc9..a097cadaa 100644 --- a/Samples/Project.swift +++ b/Samples/Project.swift @@ -26,6 +26,29 @@ let project = Project( .package(product: "SampleUI", type: .runtime), ] ), + .target( + name: "SampleTests", + destinations: .allForSample.subtracting(.visionOS), + product: .uiTests, + bundleId: "com.github.kstenerud.KSCrash.Sample.Tests", + sources: ["Tests/**"], + dependencies: [ + .target(name: "Sample"), + .package(product: "SampleUI", type: .runtime), + .package(product: "CrashTriggers", type: .runtime), + .package(product: "IntegrationTestsHelper", type: .runtime), + ], + additionalFiles: ["Tests/Integration.xctestplan"] + ), + ], + schemes: [ + .scheme( + name: "Sample", + shared: true, + buildAction: .buildAction(targets: ["Sample"]), + testAction: .testPlans(["Tests/Integration.xctestplan"], configuration: .release, attachDebugger: false), + runAction: .runAction(executable: "Sample") + ), ] ) diff --git a/Samples/Tests/Core/IntegrationTestBase.swift b/Samples/Tests/Core/IntegrationTestBase.swift new file mode 100644 index 000000000..ef26acd28 --- /dev/null +++ b/Samples/Tests/Core/IntegrationTestBase.swift @@ -0,0 +1,191 @@ +// +// IntegrationTestBase.swift +// +// Created by Nikolay Volosatov on 2024-08-03. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import XCTest +import SampleUI +import CrashTriggers +import Logging +import IntegrationTestsHelper + +class IntegrationTestBase: XCTestCase { + + private(set) var log: Logger! + private(set) var app: XCUIApplication! + + private(set) var installUrl: URL! + private(set) var appleReportsUrl: URL! + + var appLaunchTimeout: TimeInterval = 10.0 + var appTerminateTimeout: TimeInterval = 5.0 + var appCrashTimeout: TimeInterval = 10.0 + + var reportTimeout: TimeInterval = 5.0 + + lazy var actionDelay: TimeInterval = Self.defaultActionDelay + private static var defaultActionDelay: TimeInterval { +#if os(iOS) + return 5.0 +#else + return 2.0 +#endif + } + + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = true + + log = Logger(label: name) + installUrl = FileManager.default.temporaryDirectory + .appending(component: "KSCrash") + .appending(component: UUID().uuidString) + appleReportsUrl = installUrl.appending(component: "__TEST_REPORTS__") + + try FileManager.default.createDirectory(at: appleReportsUrl, withIntermediateDirectories: true) + log.info("KSCrash install path: \(installUrl.path())") + + app = XCUIApplication() + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + + app.terminate() + _ = app.wait(for: .notRunning, timeout: appTerminateTimeout) + + if let files = FileManager.default.enumerator(atPath: installUrl.path()) { + log.info("Remaining KSCrash files:") + for file in files { + log.info("\t\(file)") + } + } + + try? FileManager.default.removeItem(at: installUrl) + } + + func launchAppAndRunScript() { + app.launch() + _ = app.wait(for: .runningForeground, timeout: appLaunchTimeout) + } + + func waitForCrash() { + XCTAssert(app.wait(for: .notRunning, timeout: actionDelay + appCrashTimeout), "App crash is expected") + } + + private func waitForFile(in dir: URL, timeout: TimeInterval? = nil) throws -> URL { + enum Error: Swift.Error { + case fileNotFound + } + + let getFileUrl = { + let files = try FileManager.default.contentsOfDirectory(atPath: dir.path()) + guard let fileName = files.first else { + throw Error.fileNotFound + } + return dir.appending(component: fileName) + } + + if let timeout { + let fileExpectation = XCTNSPredicateExpectation( + predicate: .init { _, _ in (try? getFileUrl()) != nil }, + object: nil + ) + wait(for: [fileExpectation], timeout: timeout) + } + return try getFileUrl() + } + + private func findRawCrashReportUrl() throws -> URL { + enum LocalError: Error { + case reportNotFound + } + + let reportsUrl = installUrl.appending(component: "Reports") + let reportUrl = try FileManager.default + .contentsOfDirectory(atPath: reportsUrl.path()) + .first + .flatMap { reportsUrl.appending(component:$0) } + guard let reportUrl else { throw LocalError.reportNotFound } + return reportUrl + } + + func readRawCrashReportData() throws -> Data { + let reportsDirUrl = installUrl.appending(component: "Reports") + let reportUrl = try waitForFile(in: reportsDirUrl, timeout: reportTimeout) + let reportData = try Data(contentsOf: reportUrl) + return reportData + } + + func readRawCrashReport() throws -> [String: Any] { + enum LocalError: Error { + case unexpectedReportFormat + } + + let reportData = try readRawCrashReportData() + let reportObj = try JSONSerialization.jsonObject(with: reportData) + let report = reportObj as? [String:Any] + guard let report else { throw LocalError.unexpectedReportFormat } + + return report + } + + func readPartialCrashReport() throws -> PartialCrashReport { + let reportData = try readRawCrashReportData() + let report = try JSONDecoder().decode(PartialCrashReport.self, from: reportData) + return report + } + + func readAppleReport() throws -> String { + let url = try waitForFile(in: appleReportsUrl, timeout: reportTimeout) + let appleReport = try String(contentsOf: url) + return appleReport + } + + func launchAndCrash(_ crashId: CrashTriggerId, installOverride: ((inout InstallConfig) throws -> Void)? = nil) throws { + var installConfig = InstallConfig(installPath: installUrl.path()) + try installOverride?(&installConfig) + app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script( + crash: .init(triggerId: crashId), + install: installConfig, + delay: actionDelay + ) + + launchAppAndRunScript() + waitForCrash() + } + + func launchAndReportCrash() throws -> String { + app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script( + report: .init(directoryPath: appleReportsUrl.path()), + install: .init(installPath: installUrl.path()), + delay: actionDelay + ) + + launchAppAndRunScript() + let report = try readAppleReport() + return report + } +} diff --git a/Samples/Tests/Core/PartialCrashReport.swift b/Samples/Tests/Core/PartialCrashReport.swift new file mode 100644 index 000000000..fa9677f16 --- /dev/null +++ b/Samples/Tests/Core/PartialCrashReport.swift @@ -0,0 +1,61 @@ +// +// PartialCrashReport.swift +// +// Created by Nikolay Volosatov on 2024-08-03. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +struct PartialCrashReport: Decodable { + struct Crash: Decodable { + struct Error: Decodable { + var reason: String? + var type: String? + } + + struct Thread: Decodable { + struct Backtrace: Decodable { + struct Frame: Decodable { + var instruction_addr: UInt64 + + var object_addr: UInt64? + var object_name: String? + + var symbol_addr: UInt64? + var symbol_name: String? + } + + var contents: [Frame] + } + + var index: Int + var crashed: Bool + var backtrace: Backtrace + } + + var error: Error? + var threads: [Thread]? + } + + var crash: Crash? +} diff --git a/Samples/Tests/Integration.xctestplan b/Samples/Tests/Integration.xctestplan new file mode 100644 index 000000000..a936f19f0 --- /dev/null +++ b/Samples/Tests/Integration.xctestplan @@ -0,0 +1,36 @@ +{ + "configurations" : [ + { + "id" : "A64F72D9-AEC5-4B40-B99F-6BE75D506E44", + "name" : "Main", + "options" : { + "defaultTestExecutionTimeAllowance" : 300, + "diagnosticCollectionPolicy" : "Never", + "distributor" : "com.apple.TestFlight", + "mainThreadCheckerEnabled" : false, + "maximumTestRepetitions" : 3, + "testRepetitionMode" : "retryOnFailure", + "testTimeoutsEnabled" : true + } + } + ], + "defaultOptions" : { + "codeCoverage" : false, + "targetForVariableExpansion" : { + "containerPath" : "container:KSCrashSamples.xcodeproj", + "identifier" : "EE786E304B276EE93740A7FE", + "name" : "Sample" + } + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:KSCrashSamples.xcodeproj", + "identifier" : "4691483D11AE1FD180B212BC", + "name" : "SampleTests" + } + } + ], + "version" : 1 +} diff --git a/Samples/Tests/IntegrationTests.swift b/Samples/Tests/IntegrationTests.swift new file mode 100644 index 000000000..63a89a504 --- /dev/null +++ b/Samples/Tests/IntegrationTests.swift @@ -0,0 +1,81 @@ +// +// IntegrationTests.swift +// +// Created by Nikolay Volosatov on 2024-07-21. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import XCTest +import SampleUI + +final class NSExceptionTests: IntegrationTestBase { + func testGenericException() throws { + try launchAndCrash(.nsException_genericNSException) + + let rawReport = try readPartialCrashReport() + try rawReport.validate() + XCTAssertEqual(rawReport.crash?.error?.reason, "Test") + + let appleReport = try launchAndReportCrash() + XCTAssertTrue(appleReport.contains("reason: 'Test'")) + } +} + +#if os(iOS) + +final class MachTests: IntegrationTestBase { + func testBadAccess() throws { + try launchAndCrash(.mach_badAccess) + + let rawReport = try readPartialCrashReport() + try rawReport.validate() + XCTAssertEqual(rawReport.crash?.error?.type, "mach") + + let appleReport = try launchAndReportCrash() + XCTAssertTrue(appleReport.contains("SIGSEGV")) + } +} + +#endif + +final class CppTests: IntegrationTestBase { + func testRuntimeException() throws { + try launchAndCrash(.cpp_runtimeException) { + $0.isCxaThrowEnabled = true + } + + let rawReport = try readPartialCrashReport() + try rawReport.validate() + XCTAssertEqual(rawReport.crash?.error?.type, "cpp_exception") + + let appleReport = try launchAndReportCrash() + XCTAssertTrue(appleReport.contains("C++ exception")) + } +} + +extension PartialCrashReport { + func validate() throws { + let crashedThread = self.crash?.threads?.first(where: { $0.crashed }) + XCTAssertNotNil(crashedThread) + XCTAssertGreaterThan(crashedThread?.backtrace.contents.count ?? 0, 0) + } +}