Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"$schema": "https://raw.githubusercontent.com/anthropics/claude-code/refs/heads/main/marketplace/marketplace.schema.json",
"name": "diagnostics-report-analyzer-skill",
"version": "1.0.0",
"owner": {
"name": "Antoine van der Lee",
"email": "contact@avanderlee.com"
},
"metadata": {
"description": "Analyze AvdLee Diagnostics HTML reports using embedded JSON first, with legacy HTML report fallback."
},
"plugins": [
{
"name": "diagnostics-report-analyzer",
"description": "Support and debugging guidance for AvdLee Diagnostics reports, session logs, app metadata, and MetricKit diagnostics.",
"repository": "https://github.com/AvdLee/Diagnostics",
"version": "1.0.0",
"author": {
"name": "Antoine van der Lee",
"email": "contact@avanderlee.com"
},
"license": "MIT",
"category": "development",
"keywords": [
"diagnostics",
"support",
"debugging",
"logs",
"html-report",
"json",
"swift",
"ios",
"macos"
],
"tags": [
"diagnostics",
"support",
"debugging",
"logs",
"swift",
"ios",
"macos"
],
"source": "./"
}
]
}
25 changes: 25 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "diagnostics-report-analyzer",
"version": "1.0.0",
"description": "Analyze AvdLee Diagnostics HTML reports using embedded JSON first, with legacy HTML report fallback.",
"author": {
"name": "Antoine van der Lee",
"email": "contact@avanderlee.com"
},
"repository": "https://github.com/AvdLee/Diagnostics",
"license": "MIT",
"keywords": [
"diagnostics",
"support",
"debugging",
"logs",
"html-report",
"json",
"swift",
"ios",
"macos"
],
"skills": [
"./diagnostics-report-analyzer-skill"
]
}
25 changes: 25 additions & 0 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "diagnostics-report-analyzer",
"version": "1.0.0",
"description": "Analyze AvdLee Diagnostics HTML reports using embedded JSON first, with legacy HTML report fallback.",
"author": {
"name": "Antoine van der Lee",
"email": "contact@avanderlee.com"
},
"repository": "https://github.com/AvdLee/Diagnostics",
"license": "MIT",
"keywords": [
"diagnostics",
"support",
"debugging",
"logs",
"html-report",
"json",
"swift",
"ios",
"macos"
],
"skills": [
"diagnostics-report-analyzer-skill"
]
}
3 changes: 3 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
### 7.0.0
- NEW: Diagnostics reports are now agent-friendly single-file HTML documents with embedded structured JSON. The browser view is rendered from the JSON payload, while agents can inspect structured chapters, session metadata, log events, and crash diagnostics directly. Existing historic log sessions remain readable, and uncaught exceptions are persisted as timestamped `crash` events.

### 6.0.1
- Add projects using Roadmap to README ([#185](https://github.com/AvdLee/Diagnostics/pull/185)) via [@AvdLee](https://github.com/AvdLee)
- Add Helm for App Store Connect to projects list ([#186](https://github.com/AvdLee/Diagnostics/pull/186)) via [@hiddevdploeg](https://github.com/hiddevdploeg)
Expand Down
68 changes: 63 additions & 5 deletions DiagnosticsTests/DiagnosticsReporterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,63 @@ final class DiagnosticsReporterTests: XCTestCase {
let reporters = [reporter]
let report = await DiagnosticsReporter.create(using: reporters)
let html = String(data: report.data, encoding: .utf8)!

XCTAssertTrue(html.contains("<h3>\(diagnosticsChapter.title)</h3>"))
XCTAssertTrue(html.contains(diagnosticsChapter.diagnostics as! String))
let document = try XCTUnwrap(html.diagnosticsReportDocument)

XCTAssertTrue(html.contains("<script id=\"diagnostics-report-data\" type=\"application/json\">"))
XCTAssertTrue(html.contains("<main id=\"diagnostics-report\" class=\"container\"></main>"))
XCTAssertEqual(document.chapters.first?.title, diagnosticsChapter.title)
if case .text(let value)? = document.chapters.first?.data {
XCTAssertEqual(value, diagnosticsChapter.diagnostics as! String)
} else {
XCTFail("Expected text diagnostics")
}
}

/// It should create a chapter for each reporter.
func testReportingChapters() async throws {
let report = await DiagnosticsReporter.create()
let html = String(data: report.data, encoding: .utf8)!
let document = try XCTUnwrap(html.diagnosticsReportDocument)
let expectedChaptersCount = DiagnosticsReporter.DefaultReporter.allCases.count
let chaptersCount = html.components(separatedBy: "<div class=\"chapter\"").count - 1
XCTAssertEqual(expectedChaptersCount, chaptersCount)
XCTAssertEqual(expectedChaptersCount, document.chapters.count)
}

func testGeneralInfoPreservesFormattedHTMLAndHiddenTitle() async throws {
let report = await DiagnosticsReporter.create(using: [GeneralInfoReporter()])
let html = String(data: report.data, encoding: .utf8)!
let document = try XCTUnwrap(html.diagnosticsReportDocument)
let chapter = try XCTUnwrap(document.chapters.first)

XCTAssertFalse(chapter.showTitle)
XCTAssertTrue(chapter.legacyHTML?.contains("<p>This diagnostics report can help") == true)
if case .text(let value) = chapter.data {
XCTAssertTrue(value.contains("<p>This diagnostics report can help"))
} else {
XCTFail("Expected text diagnostics")
}
}

func testCustomFormatterPreservesBrowserHTMLAndStructuredData() async throws {
let suiteName = "DiagnosticsReporterTests-\(UUID().uuidString)"
let userDefaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defer { userDefaults.removePersistentDomain(forName: suiteName) }

userDefaults.set("agent-friendly", forKey: "mode")
let reporter = UserDefaultsReporter(userDefaults: userDefaults, keys: ["mode"])

let report = await DiagnosticsReporter.create(using: [reporter])
let html = String(data: report.data, encoding: .utf8)!
let document = try XCTUnwrap(html.diagnosticsReportDocument)
let chapter = try XCTUnwrap(document.chapters.first)

XCTAssertTrue(chapter.legacyHTML?.contains("<pre>") == true)
XCTAssertTrue(chapter.legacyHTML?.contains("agent-friendly") == true)
if case .table(let rows) = chapter.data {
XCTAssertEqual(rows.first?.key, "mode")
XCTAssertEqual(rows.first?.value, "agent-friendly")
} else {
XCTFail("Expected table diagnostics")
}
}

/// It should filter using passed filters.
Expand Down Expand Up @@ -88,6 +133,19 @@ final class DiagnosticsReporterTests: XCTestCase {
}
}

private extension String {
var diagnosticsReportDocument: DiagnosticsReportDocument? {
guard
let startRange = range(of: "<script id=\"diagnostics-report-data\" type=\"application/json\">"),
let endRange = range(of: "</script>", range: startRange.upperBound..<endIndex) else {
return nil
}

let json = String(self[startRange.upperBound..<endRange.lowerBound])
return try? JSONDecoder().decode(DiagnosticsReportDocument.self, from: Data(json.utf8))
}
}

struct MockedReport: DiagnosticsReporting {
var diagnostics: Diagnostics = [String: String]()
func report() -> DiagnosticsChapter {
Expand Down
24 changes: 24 additions & 0 deletions DiagnosticsTests/Logging/DiagnosticsLoggerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ import XCTest

final class DiagnosticsLoggerTests: XCTestCase {

override func setUpWithError() throws {
try super.setUpWithError()
try DiagnosticsLogger.setup()
}

override func tearDownWithError() throws {
try DiagnosticsLogger.standard.deleteLogs()
try super.tearDownWithError()
}

func testSynchronousCrashLogIsPersistedBeforeReturning() throws {
let exception = NSException(name: .genericException, reason: "Synchronous crash test")

DiagnosticsLogger.standard.logSynchronously(ExceptionLog(exception, description: "Uncaught Exception"))

let logData = try XCTUnwrap(DiagnosticsLogger.standard.readLog())
let log = String(decoding: logData, as: UTF8.self)

XCTAssertTrue(log.contains(DiagnosticsLogRecord.linePrefix))
XCTAssertTrue(log.contains("\"level\":\"crash\""))
XCTAssertTrue(log.contains("Synchronous crash test"))
XCTAssertTrue(log.contains("Uncaught Exception"))
}

#if os(macOS)
/// On unsandboxed macOS processes (including the `swift test` runner), the Application
/// Support directory used for the log file must be scoped by the current bundle identifier
Expand Down
20 changes: 20 additions & 0 deletions DiagnosticsTests/Logging/LogsWriterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ final class LogsWriterTests: XCTestCase {
let contents = String(decoding: data, as: UTF8.self)

XCTAssertTrue(contents.contains("Test log line"))
XCTAssertTrue(contents.contains(DiagnosticsLogRecord.linePrefix))
}

func testWriteAppendsStructuredDataAfterLegacyContent() throws {
let legacyContent = """
<summary><div class="session-header"><p><span>Date: </span>2026-01-01</p></div></summary>
<p class="debug"><span class="log-message">Legacy event</span></p>

"""
try Data(legacyContent.utf8).write(to: tempLogFileURL)

let writer = LogsWriter(logFileLocation: tempLogFileURL, maximumLogSize: 1024 * 1024)
writer.write(SystemLog(line: "Structured event"))

let data = try Data(contentsOf: tempLogFileURL)
let contents = String(decoding: data, as: UTF8.self)

XCTAssertTrue(contents.contains("Legacy event"))
XCTAssertTrue(contents.contains("Structured event"))
XCTAssertTrue(contents.contains(DiagnosticsLogRecord.linePrefix))
}

func testTrimmingOccursWhenExceedingMaxSize() throws {
Expand Down
88 changes: 74 additions & 14 deletions DiagnosticsTests/Reporters/LogsReporterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@ final class LogsReporterTests: XCTestCase {
let identifier = UUID().uuidString
let message = "<b>\(identifier)</b>"
DiagnosticsLogger.log(message: message)
let diagnostics = LogsReporter().report().diagnostics as! String
XCTAssertTrue(diagnostics.contains(identifier), "Diagnostics is \(diagnostics)")
XCTAssertEqual(diagnostics.debugLogs.count, 1)
let debugLog = try XCTUnwrap(diagnostics.debugLogs.first)
XCTAssertTrue(debugLog.contains("<span class=\"log-prefix\">LogsReporterTests.swift:L28</span>"), "Prefix should be added")
XCTAssertTrue(debugLog.contains("<span class=\"log-message\">&lt;b&gt;\(identifier)&lt;/b&gt;</span>"), "Log message should be added to \(debugLog)")
let diagnostics = LogsReporter().report().diagnostics as! DiagnosticsLogReport
let debugLogs = diagnostics.sessions.flatMap(\.events).filter { $0.level == "debug" }
let html = diagnostics.html()
XCTAssertTrue(html.contains(identifier), "Diagnostics is \(html)")
XCTAssertEqual(debugLogs.count, 1)
let debugLog = try XCTUnwrap(debugLogs.first)
XCTAssertEqual(debugLog.prefix, "LogsReporterTests.swift:L28", "Prefix should be added")
XCTAssertEqual(debugLog.message, message, "Raw message should be preserved for agents")
XCTAssertTrue(html.contains("<span class=\"log-prefix\">LogsReporterTests.swift:L28</span>"), "Prefix should be added")
XCTAssertTrue(html.contains("<span class=\"log-message\">&lt;b&gt;\(identifier)&lt;/b&gt;</span>"), "Log message should be added to \(html)")
}

/// It should show errors.
Expand All @@ -45,21 +49,77 @@ final class LogsReporterTests: XCTestCase {
}

DiagnosticsLogger.log(error: Error.testCase)
let diagnostics = LogsReporter().report().diagnostics as! String
XCTAssertTrue(diagnostics.contains("testCase"))
XCTAssertEqual(diagnostics.errorLogs.count, 1)
let errorLog = try XCTUnwrap(diagnostics.errorLogs.first)
XCTAssertTrue(errorLog.contains("<span class=\"log-message\">ERROR: testCase | &lt;b&gt;example description&lt;/b&gt"))
let diagnostics = LogsReporter().report().diagnostics as! DiagnosticsLogReport
let errorLogs = diagnostics.sessions.flatMap(\.events).filter { $0.level == "error" }
let html = diagnostics.html()
XCTAssertTrue(html.contains("testCase"))
XCTAssertEqual(errorLogs.count, 1)
let errorLog = try XCTUnwrap(errorLogs.first)
XCTAssertTrue(errorLog.message.contains("ERROR: testCase | <b>example description</b>"))
XCTAssertTrue(html.contains("<span class=\"log-message\">ERROR: testCase | &lt;b&gt;example description&lt;/b&gt"))
}

/// It should reverse the order of sessions to have the most recent session on top.
func testReverseSessions() throws {
DiagnosticsLogger.log(message: "first")
DiagnosticsLogger.standard.startNewSession()
DiagnosticsLogger.log(message: "second")
let diagnostics = LogsReporter().report().diagnostics as! String
let firstIndex = try XCTUnwrap(diagnostics.range(of: "first")?.lowerBound)
let secondIndex = try XCTUnwrap(diagnostics.range(of: "second")?.lowerBound)
let diagnostics = LogsReporter().report().diagnostics as! DiagnosticsLogReport
let html = diagnostics.html()
let firstIndex = try XCTUnwrap(html.range(of: "first")?.lowerBound)
let secondIndex = try XCTUnwrap(html.range(of: "second")?.lowerBound)
XCTAssertTrue(firstIndex > secondIndex)
}

/// It should keep historic legacy sessions readable when new structured records are appended after an app update.
func testMixedLegacyAndStructuredSessions() throws {
let legacySession = """

---

<summary><div class="session-header"><p><span>Date: </span>2026-01-01 10:00:00</p><p><span>System: </span>iOS 18.0</p><p><span>Locale: </span>en</p><p><span>Version: </span>1.0 (1)</p></div></summary>
<p class="debug"><span class="log-message">legacy historic event</span></p>
"""
let structuredSession = String(decoding: NewSession().logData, as: UTF8.self)
let structuredEvent = String(decoding: LogItem(.debug(message: "structured update event"), file: #file, function: #function, line: #line).logData, as: UTF8.self)

let report = DiagnosticsLogParser().parse(legacySession + structuredSession + structuredEvent)
let html = report.html()

XCTAssertEqual(report.sessions.count, 2)
XCTAssertTrue(html.contains("legacy historic event"))
XCTAssertTrue(html.contains("structured update event"))
let legacyIndex = try XCTUnwrap(html.range(of: "legacy historic event")?.lowerBound)
let structuredIndex = try XCTUnwrap(html.range(of: "structured update event")?.lowerBound)
XCTAssertTrue(structuredIndex < legacyIndex)
}

/// It should encode structured session metadata keys and values before rendering HTML.
func testStructuredSessionMetadataHTMLEncoding() {
let session = DiagnosticsLogSession(
title: "Session",
metadata: ["<Date>": "<2026-06-19>"]
)

let html = session.html()

XCTAssertTrue(html.contains("&lt;Date&gt;"))
XCTAssertTrue(html.contains("&lt;2026-06-19&gt;"))
XCTAssertFalse(html.contains("<span><Date>: </span>"))
}

func testExceptionLogUsesCrashLevelAndPreformattedHTML() throws {
let exception = NSException(name: .genericException, reason: "Boom")
let recordLine = String(decoding: ExceptionLog(exception, description: "Crash description").logData, as: UTF8.self)

let report = DiagnosticsLogParser().parse(recordLine)
let event = try XCTUnwrap(report.sessions.first?.events.first)
let html = event.html()

XCTAssertEqual(event.level, "crash")
XCTAssertNotNil(event.date)
XCTAssertTrue(event.message.contains("CRASH:"))
XCTAssertTrue(html.contains("<pre class=\"crash\">"))
XCTAssertTrue(html.contains("Crash description"))
}
}
Loading
Loading