Skip to content

Commit

Permalink
Add reportNSException method and deprecate old API (#588)
Browse files Browse the repository at this point in the history
* Add reportNSException method and deprecate currentSnapshotUserReportedExceptionHandler

* Fix unit tests

* Add missing import

* Add integration test for NSException reporting

* Add user report test

* Check KSCrash state after user reported crashes

* Resolve implicit merge conflict

* Use explicit assert messages in tests

* Address review comments
  • Loading branch information
bamx23 authored Nov 5, 2024
1 parent 789258a commit 4b5ef05
Show file tree
Hide file tree
Showing 18 changed files with 379 additions and 50 deletions.
12 changes: 12 additions & 0 deletions Samples/Common/Sources/CrashTriggers/KSCrashTriggersHelper.m
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,16 @@ + (void)runTrigger:(KSCrashTriggerId)triggerId
#undef __PROCESS_TRIGGER
}

#pragma mark - Utilities

+ (NSException *)exceptionWithStacktraceForException:(NSException *)exception
{
@try {
[exception raise];
} @catch (NSException *exceptionWithStacktrace) {
return exceptionWithStacktrace;
}
return exception;
}

@end
2 changes: 2 additions & 0 deletions Samples/Common/Sources/CrashTriggers/KSCrashTriggersList.mm
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ void KSStacktraceCheckCrash() __attribute__((disable_tail_calls))
[exc raise];
}

NSString *const KSCrashNSExceptionStacktraceFuncName = @"exceptionWithStacktraceForException";

@implementation KSCrashTriggersList

+ (void)trigger_nsException_genericNSException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ NS_SWIFT_NAME(CrashTriggersHelper)

+ (void)runTrigger:(KSCrashTriggerId)triggerId;

#pragma mark - Utilities

+ (NSException *)exceptionWithStacktraceForException:(NSException *)exception;

@end

NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
NS_ASSUME_NONNULL_BEGIN

extern NSString *const KSCrashStacktraceCheckFuncName;
extern NSString *const KSCrashNSExceptionStacktraceFuncName;

#define __ALL_GROUPS \
__PROCESS_GROUP(nsException, @"NSException") \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,23 @@ import Foundation

public final class IntegrationTestRunner {

public struct RunConfig: Codable {
var delay: TimeInterval?
var stateSavePath: String?

public init(delay: TimeInterval? = nil, stateSavePath: String? = nil) {
self.delay = delay
self.stateSavePath = stateSavePath
}
}

private struct Script: Codable {
var install: InstallConfig?
var userReports: [UserReportConfig]?
var crashTrigger: CrashTriggerConfig?
var report: ReportConfig?

var delay: TimeInterval?
var config: RunConfig?
}

public static let runScriptAccessabilityId = "run-integration-test"
Expand All @@ -53,11 +64,19 @@ public final class IntegrationTestRunner {
if let installConfig = script.install {
try! installConfig.install()
}
if let statePath = script.config?.stateSavePath {
try! KSCrashState.collect().save(to: statePath)
}

DispatchQueue.main.asyncAfter(deadline: .now() + (script.delay ?? 0)) {
DispatchQueue.main.asyncAfter(deadline: .now() + (script.config?.delay ?? 0)) {
if let crashTrigger = script.crashTrigger {
crashTrigger.crash()
}
if let userReports = script.userReports {
for report in userReports {
report.report()
}
}
if let report = script.report {
report.report()
}
Expand All @@ -70,18 +89,23 @@ public final class IntegrationTestRunner {
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))
static func script(crash: CrashTriggerConfig, install: InstallConfig? = nil, config: RunConfig? = nil) throws -> String {
let data = try JSONEncoder().encode(Script(install: install, crashTrigger: crash, config: config))
return data.base64EncodedString()
}

static func script(userReports: [UserReportConfig], install: InstallConfig? = nil, config: RunConfig? = nil) throws -> String {
let data = try JSONEncoder().encode(Script(install: install, userReports: userReports, config: config))
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))
static func script(report: ReportConfig, install: InstallConfig? = nil, config: RunConfig? = nil) throws -> String {
let data = try JSONEncoder().encode(Script(install: install, report: report, config: config))
return data.base64EncodedString()
}

static func script(install: InstallConfig? = nil, delay: TimeInterval? = nil) throws -> String {
let data = try JSONEncoder().encode(Script(install: install, delay: delay))
static func script(install: InstallConfig? = nil, config: RunConfig? = nil) throws -> String {
let data = try JSONEncoder().encode(Script(install: install, config: config))
return data.base64EncodedString()
}
}
62 changes: 62 additions & 0 deletions Samples/Common/Sources/IntegrationTestsHelper/KSCrashState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// KSCrashState.swift
//
// Created by Nikolay Volosatov on 2024-11-02.
//
// 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 KSCrashRecording

public struct KSCrashState: Codable {
public var sessionsSinceLaunch: Int
public var activeDurationSinceLaunch: TimeInterval
public var backgroundDurationSinceLaunch: TimeInterval

public var launchesSinceLastCrash: Int
public var sessionsSinceLastCrash: Int
public var activeDurationSinceLastCrash: TimeInterval
public var backgroundDurationSinceLastCrash: TimeInterval

public var crashedLastLaunch: Bool
}

extension KSCrashState {
static func collect() -> Self {
.init(
sessionsSinceLaunch: KSCrash.shared.sessionsSinceLaunch,
activeDurationSinceLaunch: KSCrash.shared.activeDurationSinceLaunch,
backgroundDurationSinceLaunch: KSCrash.shared.backgroundDurationSinceLaunch,
launchesSinceLastCrash: KSCrash.shared.launchesSinceLastCrash,
sessionsSinceLastCrash: KSCrash.shared.sessionsSinceLastCrash,
activeDurationSinceLastCrash: KSCrash.shared.activeDurationSinceLastCrash,
backgroundDurationSinceLastCrash: KSCrash.shared.backgroundDurationSinceLastCrash,
crashedLastLaunch: KSCrash.shared.crashedLastLaunch
)
}

func save(to path: String) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
try encoder.encode(self).write(to: URL(fileURLWithPath: path))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// UserReportConfig.swift
//
// Created by Nikolay Volosatov on 2024-11-02.
//
// 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 CrashTriggers
import KSCrashRecording

public struct UserReportConfig: Codable {
public enum ReportType: String, Codable {
case userException
case nsException
}

public var reportType: ReportType

public init(reportType: ReportType) {
self.reportType = reportType
}
}

extension UserReportConfig {
public static let crashName = "Crash Name"
public static let crashReason = "Crash Reason"
public static let crashLanguage = "Crash Language"
public static let crashLineOfCode = "108"
public static let crashCustomStacktrace = ["func01", "func02", "func03"]

func report() {
switch reportType {
case .userException:
KSCrash.shared.reportUserException(
Self.crashName,
reason: Self.crashReason,
language: Self.crashLanguage,
lineOfCode: Self.crashLineOfCode,
stackTrace: Self.crashCustomStacktrace,
logAllThreads: true,
terminateProgram: false
)
case .nsException:
KSCrash.shared.report(
CrashTriggersHelper.exceptionWithStacktrace(for: NSException(
name: .init(rawValue:Self.crashName),
reason: Self.crashReason,
userInfo: ["a":"b"]
)),
logAllThreads: true
)
}
}
}
43 changes: 39 additions & 4 deletions Samples/Tests/Core/IntegrationTestBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,16 @@ class IntegrationTestBase: XCTestCase {

private(set) var installUrl: URL!
private(set) var appleReportsUrl: URL!
private(set) var stateUrl: URL!

var appLaunchTimeout: TimeInterval = 10.0
var appTerminateTimeout: TimeInterval = 5.0
var appCrashTimeout: TimeInterval = 10.0

var reportTimeout: TimeInterval = 5.0

var expectSingleCrash: Bool = true

lazy var actionDelay: TimeInterval = Self.defaultActionDelay
private static var defaultActionDelay: TimeInterval {
#if os(iOS)
Expand All @@ -53,6 +56,13 @@ class IntegrationTestBase: XCTestCase {
#endif
}

private var runConfig: IntegrationTestRunner.RunConfig {
.init(
delay: actionDelay,
stateSavePath: stateUrl.path
)
}

override func setUpWithError() throws {
try super.setUpWithError()

Expand All @@ -63,6 +73,7 @@ class IntegrationTestBase: XCTestCase {
.appendingPathComponent("KSCrash")
.appendingPathComponent(UUID().uuidString)
appleReportsUrl = installUrl.appendingPathComponent("__TEST_REPORTS__")
stateUrl = installUrl.appendingPathComponent("__test_state__.json")

try FileManager.default.createDirectory(at: appleReportsUrl, withIntermediateDirectories: true)
log.info("KSCrash install path: \(installUrl.path)")
Expand Down Expand Up @@ -98,13 +109,19 @@ class IntegrationTestBase: XCTestCase {
private func waitForFile(in dir: URL, timeout: TimeInterval? = nil) throws -> URL {
enum Error: Swift.Error {
case fileNotFound
case tooManyFiles
}

let getFileUrl = {
let getFileUrl = { [unowned self] in
let files = try FileManager.default.contentsOfDirectory(atPath: dir.path)
guard let fileName = files.first else {
throw Error.fileNotFound
}
if self.expectSingleCrash {
guard files.count == 1 else {
throw Error.tooManyFiles
}
}
return dir.appendingPathComponent(fileName)
}

Expand Down Expand Up @@ -175,7 +192,7 @@ class IntegrationTestBase: XCTestCase {
try installOverride?(&installConfig)
app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script(
install: installConfig,
delay: actionDelay
config: runConfig
)

launchAppAndRunScript()
Expand All @@ -187,25 +204,43 @@ class IntegrationTestBase: XCTestCase {
app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script(
crash: .init(triggerId: crashId),
install: installConfig,
delay: actionDelay
config: runConfig
)

launchAppAndRunScript()
waitForCrash()
}

func launchAndMakeUserReports(_ reportTypes: [UserReportConfig.ReportType], installOverride: ((inout InstallConfig) throws -> Void)? = nil) throws {
var installConfig = InstallConfig(installPath: installUrl.path)
try installOverride?(&installConfig)
app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script(
userReports: reportTypes.map(UserReportConfig.init(reportType:)),
install: installConfig,
config: runConfig
)

launchAppAndRunScript()
}

func launchAndReportCrash() throws -> String {
app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script(
report: .init(directoryPath: appleReportsUrl.path),
install: .init(installPath: installUrl.path),
delay: actionDelay
config: runConfig
)

launchAppAndRunScript()
let report = try readAppleReport()
return report
}

func readState() throws -> KSCrashState {
let data = try Data(contentsOf: stateUrl)
let state = try JSONDecoder().decode(KSCrashState.self, from: data)
return state
}

func terminate() throws {
app.terminate()
_ = app.wait(for: .notRunning, timeout: self.appTerminateTimeout)
Expand Down
12 changes: 12 additions & 0 deletions Samples/Tests/Core/PartialCrashReport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,23 @@ struct PartialCrashReport: Decodable {
var code: Int?
var code_name: String?
}
struct NSException: Decodable {
var name: String?
var userInfo: String?
}
struct UserReported: Decodable {
var name: String?
var language: String?
var line_of_code: String?
var backtrace: [String]? // Can be actually any JSON encodable structure
}

var reason: String?
var type: String?

var signal: Signal?
var nsexception: NSException?
var user_reported: UserReported?
}

struct Thread: Decodable {
Expand Down
Loading

0 comments on commit 4b5ef05

Please sign in to comment.