diff --git a/CHANGELOG.md b/CHANGELOG.md index 008861b6..41871d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - appstoreconnect-swift-sdk updated to 4.5.1 (App Store Connect API 4.5 and 4.5.1). - `asc screenshot-sets`, `screenshots`, `app-preview-sets` and `app-previews` say in `--help` that Apple deprecated them; they work as before. → [docs](docs/features/asset-library/README.md) +- `asc perf-metrics`, `asc diagnostics` and `asc diagnostic-logs` are now also available over REST (`asc web-server`), with `_links` to the next call. → [docs](docs/features/performance/README.md) - asc is now licensed under Apache 2.0 instead of MIT. It stays free and open source, and the license now also grants patent rights. → [LICENSE](LICENSE) ### Added diff --git a/Sources/ASCCommand/Commands/Performance/DiagnosticLogsCommand.swift b/Sources/ASCCommand/Commands/Performance/DiagnosticLogsCommand.swift index b4aafda8..f505567c 100644 --- a/Sources/ASCCommand/Commands/Performance/DiagnosticLogsCommand.swift +++ b/Sources/ASCCommand/Commands/Performance/DiagnosticLogsCommand.swift @@ -26,22 +26,9 @@ struct DiagnosticLogsList: AsyncParsableCommand { print(try await execute(repo: repo)) } - func execute(repo: any DiagnosticsRepository) async throws -> String { + func execute(repo: any DiagnosticsRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { let logs = try await repo.listLogs(signatureId: signatureId) let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) - return try formatter.formatAgentItems( - logs, - headers: ["ID", "Bundle ID", "Version", "OS", "Device", "Event"], - rowMapper: { - [ - $0.id, - $0.bundleId ?? "-", - $0.appVersion ?? "-", - $0.osVersion ?? "-", - $0.deviceType ?? "-", - $0.event ?? "-", - ] - } - ) + return try formatter.formatAgentItems(logs, affordanceMode: affordanceMode) } } diff --git a/Sources/ASCCommand/Commands/Performance/DiagnosticsCommand.swift b/Sources/ASCCommand/Commands/Performance/DiagnosticsCommand.swift index 92b96af0..c24c4310 100644 --- a/Sources/ASCCommand/Commands/Performance/DiagnosticsCommand.swift +++ b/Sources/ASCCommand/Commands/Performance/DiagnosticsCommand.swift @@ -29,22 +29,10 @@ struct DiagnosticsList: AsyncParsableCommand { print(try await execute(repo: repo)) } - func execute(repo: any DiagnosticsRepository) async throws -> String { + func execute(repo: any DiagnosticsRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { let filter = diagnosticType.flatMap { DiagnosticType(rawValue: $0) } let signatures = try await repo.listSignatures(buildId: buildId, diagnosticType: filter) let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) - return try formatter.formatAgentItems( - signatures, - headers: ["ID", "Type", "Signature", "Weight", "Trend"], - rowMapper: { - [ - $0.id, - $0.diagnosticType.rawValue, - $0.signature, - String($0.weight), - $0.insightDirection ?? "-", - ] - } - ) + return try formatter.formatAgentItems(signatures, affordanceMode: affordanceMode) } } diff --git a/Sources/ASCCommand/Commands/Performance/PerfMetricsCommand.swift b/Sources/ASCCommand/Commands/Performance/PerfMetricsCommand.swift index bc485ddc..8860baf4 100644 --- a/Sources/ASCCommand/Commands/Performance/PerfMetricsCommand.swift +++ b/Sources/ASCCommand/Commands/Performance/PerfMetricsCommand.swift @@ -32,7 +32,7 @@ struct PerfMetricsList: AsyncParsableCommand { print(try await execute(repo: repo)) } - func execute(repo: any PerfMetricsRepository) async throws -> String { + func execute(repo: any PerfMetricsRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { let filter = metricType.flatMap { PerformanceMetricCategory(rawValue: $0) } let metrics: [PerformanceMetric] @@ -45,19 +45,6 @@ struct PerfMetricsList: AsyncParsableCommand { } let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) - return try formatter.formatAgentItems( - metrics, - headers: ["ID", "Category", "Metric", "Value", "Unit", "Goal"], - rowMapper: { m in - [ - m.id, - m.category.rawValue, - m.metricIdentifier, - m.latestValue.map { String($0) } ?? "-", - m.unit ?? "-", - m.goalValue.map { String($0) } ?? "-", - ] - } - ) + return try formatter.formatAgentItems(metrics, affordanceMode: affordanceMode) } } diff --git a/Sources/ASCCommand/Commands/Web/Controllers/DiagnosticsController.swift b/Sources/ASCCommand/Commands/Web/Controllers/DiagnosticsController.swift new file mode 100644 index 00000000..09d5cd30 --- /dev/null +++ b/Sources/ASCCommand/Commands/Web/Controllers/DiagnosticsController.swift @@ -0,0 +1,28 @@ +import Foundation +import Domain +import Hummingbird +import HummingbirdWebSocket +import Infrastructure + +/// `GET /builds/:buildId/diagnostics?diagnostic-type=…` — a build's diagnostic signatures; +/// `GET /diagnostics/:signatureId/logs` — the logs behind one signature. +/// The query param matches the CLI flag `--diagnostic-type`. +struct DiagnosticsController: Sendable { + let repo: any DiagnosticsRepository + + func addRoutes(to group: RouterGroup) { + group.get("/builds/:buildId/diagnostics") { request, context -> Response in + guard let buildId = context.parameters.get("buildId") else { return jsonError("Missing buildId") } + let diagnosticType = request.uri.queryParameters.get("diagnostic-type") + .flatMap { DiagnosticType(rawValue: String($0)) } + let signatures = try await self.repo.listSignatures(buildId: buildId, diagnosticType: diagnosticType) + return try restFormat(signatures) + } + + group.get("/diagnostics/:signatureId/logs") { _, context -> Response in + guard let signatureId = context.parameters.get("signatureId") else { return jsonError("Missing signatureId") } + let logs = try await self.repo.listLogs(signatureId: signatureId) + return try restFormat(logs) + } + } +} diff --git a/Sources/ASCCommand/Commands/Web/Controllers/PerfMetricsController.swift b/Sources/ASCCommand/Commands/Web/Controllers/PerfMetricsController.swift new file mode 100644 index 00000000..38a112b0 --- /dev/null +++ b/Sources/ASCCommand/Commands/Web/Controllers/PerfMetricsController.swift @@ -0,0 +1,29 @@ +import Foundation +import Domain +import Hummingbird +import HummingbirdWebSocket +import Infrastructure + +/// `GET /apps/:appId/perf-metrics` and `GET /builds/:buildId/perf-metrics` — power and +/// performance metrics. The `metric-type` query param matches the CLI flag `--metric-type`. +struct PerfMetricsController: Sendable { + let repo: any PerfMetricsRepository + + func addRoutes(to group: RouterGroup) { + group.get("/apps/:appId/perf-metrics") { request, context -> Response in + guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") } + let metrics = try await self.repo.listAppMetrics(appId: appId, metricType: Self.metricType(request)) + return try restFormat(metrics) + } + + group.get("/builds/:buildId/perf-metrics") { request, context -> Response in + guard let buildId = context.parameters.get("buildId") else { return jsonError("Missing buildId") } + let metrics = try await self.repo.listBuildMetrics(buildId: buildId, metricType: Self.metricType(request)) + return try restFormat(metrics) + } + } + + private static func metricType(_ request: Request) -> PerformanceMetricCategory? { + request.uri.queryParameters.get("metric-type").flatMap { PerformanceMetricCategory(rawValue: String($0)) } + } +} diff --git a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift index 53533945..e979e971 100644 --- a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift +++ b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift @@ -92,6 +92,12 @@ enum RESTRoutes { if let perfOverviewRepo = try? factory.makePerfOverviewRepository(authProvider: auth) { PerfOverviewController(repo: perfOverviewRepo).addRoutes(to: v1) } + if let perfMetricsRepo = try? factory.makePerfMetricsRepository(authProvider: auth) { + PerfMetricsController(repo: perfMetricsRepo).addRoutes(to: v1) + } + if let diagnosticsRepo = try? factory.makeDiagnosticsRepository(authProvider: auth) { + DiagnosticsController(repo: diagnosticsRepo).addRoutes(to: v1) + } if let experimentRepo = try? factory.makeExperimentRepository(authProvider: auth) { ExperimentsController(repo: experimentRepo).addRoutes(to: v1) } diff --git a/Sources/Domain/Apps/Performance/DiagnosticLogEntry.swift b/Sources/Domain/Apps/Performance/DiagnosticLogEntry.swift index 0f5b2164..0e25de9d 100644 --- a/Sources/Domain/Apps/Performance/DiagnosticLogEntry.swift +++ b/Sources/Domain/Apps/Performance/DiagnosticLogEntry.swift @@ -65,9 +65,19 @@ public struct DiagnosticLogEntry: Sendable, Equatable, Identifiable, Codable { } extension DiagnosticLogEntry: AffordanceProviding { - public var affordances: [String: String] { + public var structuredAffordances: [Affordance] { [ - "listLogs": "asc diagnostic-logs list --signature-id \(signatureId)", + Affordance(key: "listLogs", command: "diagnostic-logs", action: "list", params: ["signature-id": signatureId]), ] } } + +extension DiagnosticLogEntry: Presentable { + public static var tableHeaders: [String] { + ["ID", "Bundle ID", "Version", "OS", "Device", "Event"] + } + + public var tableRow: [String] { + [id, bundleId ?? "-", appVersion ?? "-", osVersion ?? "-", deviceType ?? "-", event ?? "-"] + } +} diff --git a/Sources/Domain/Apps/Performance/DiagnosticSignatureInfo.swift b/Sources/Domain/Apps/Performance/DiagnosticSignatureInfo.swift index 833a7f97..2545cddc 100644 --- a/Sources/Domain/Apps/Performance/DiagnosticSignatureInfo.swift +++ b/Sources/Domain/Apps/Performance/DiagnosticSignatureInfo.swift @@ -56,10 +56,20 @@ public struct DiagnosticSignatureInfo: Sendable, Equatable, Identifiable, Codabl } extension DiagnosticSignatureInfo: AffordanceProviding { - public var affordances: [String: String] { + public var structuredAffordances: [Affordance] { [ - "listLogs": "asc diagnostic-logs list --signature-id \(id)", - "listSignatures": "asc diagnostics list --build-id \(buildId)", + Affordance(key: "listLogs", command: "diagnostic-logs", action: "list", params: ["signature-id": id]), + Affordance(key: "listSignatures", command: "diagnostics", action: "list", params: ["build-id": buildId]), ] } } + +extension DiagnosticSignatureInfo: Presentable { + public static var tableHeaders: [String] { + ["ID", "Type", "Signature", "Weight", "Trend"] + } + + public var tableRow: [String] { + [id, diagnosticType.rawValue, signature, String(weight), insightDirection ?? "-"] + } +} diff --git a/Sources/Domain/Apps/Performance/PerfPowerMetric.swift b/Sources/Domain/Apps/Performance/PerfPowerMetric.swift index 4a3a6aa1..6be1477a 100644 --- a/Sources/Domain/Apps/Performance/PerfPowerMetric.swift +++ b/Sources/Domain/Apps/Performance/PerfPowerMetric.swift @@ -87,14 +87,29 @@ public struct PerformanceMetric: Sendable, Equatable, Identifiable, Codable { } extension PerformanceMetric: AffordanceProviding { - public var affordances: [String: String] { - var cmds: [String: String] = [:] + public var structuredAffordances: [Affordance] { switch parentType { case .app: - cmds["listAppMetrics"] = "asc perf-metrics list --app-id \(parentId)" + return [Affordance(key: "listAppMetrics", command: "perf-metrics", action: "list", params: ["app-id": parentId])] case .build: - cmds["listBuildMetrics"] = "asc perf-metrics list --build-id \(parentId)" + return [Affordance(key: "listBuildMetrics", command: "perf-metrics", action: "list", params: ["build-id": parentId])] } - return cmds + } +} + +extension PerformanceMetric: Presentable { + public static var tableHeaders: [String] { + ["ID", "Category", "Metric", "Value", "Unit", "Goal"] + } + + public var tableRow: [String] { + [ + id, + category.rawValue, + metricIdentifier, + latestValue.map { String($0) } ?? "-", + unit ?? "-", + goalValue.map { String($0) } ?? "-", + ] } } diff --git a/Sources/Domain/Apps/Performance/Performance+RESTRoutes.swift b/Sources/Domain/Apps/Performance/Performance+RESTRoutes.swift new file mode 100644 index 00000000..6488aed2 --- /dev/null +++ b/Sources/Domain/Apps/Performance/Performance+RESTRoutes.swift @@ -0,0 +1,9 @@ +/// REST route registrations for build performance metrics and diagnostics. +/// (`perf-metrics` under an app is registered with the other app children.) +extension RESTPathResolver { + static let _performanceRoutes: Void = { + registerRoute(command: "perf-metrics", parentParam: "build-id", parentSegment: "builds", segment: "perf-metrics") + registerRoute(command: "diagnostics", parentParam: "build-id", parentSegment: "builds", segment: "diagnostics") + registerRoute(command: "diagnostic-logs", parentParam: "signature-id", parentSegment: "diagnostics", segment: "logs") + }() +} diff --git a/Sources/Domain/Shared/RESTPathResolver.swift b/Sources/Domain/Shared/RESTPathResolver.swift index ec0ea7ce..f1e47dc9 100644 --- a/Sources/Domain/Shared/RESTPathResolver.swift +++ b/Sources/Domain/Shared/RESTPathResolver.swift @@ -199,6 +199,7 @@ public final class RESTPathResolver: @unchecked Sendable { _ = _resolutionCenterRoutes _ = _experimentRoutes _ = _perfOverviewRoutes + _ = _performanceRoutes _ = _gameCenterRoutes _ = _assetLibraryRoutes diff --git a/Sources/Infrastructure/Apps/AssetLibrary/Images/SDKLibraryImageRepository.swift b/Sources/Infrastructure/Apps/AssetLibrary/Images/SDKLibraryImageRepository.swift index 1b7f1335..625b9d36 100644 --- a/Sources/Infrastructure/Apps/AssetLibrary/Images/SDKLibraryImageRepository.swift +++ b/Sources/Infrastructure/Apps/AssetLibrary/Images/SDKLibraryImageRepository.swift @@ -18,8 +18,8 @@ public struct SDKLibraryImageRepository: LibraryImageRepository, @unchecked Send category: AssetCategory? ) async throws -> [LibraryImage] { let sdk = APIEndpoint.v1.appAssetLibraries.id(libraryId).images.get(parameters: .init( - filterCategory: category.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, - filterState: state.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterCategory: sdkFilter(category), + filterState: sdkFilter(state), filterID: imageId.map { [$0] }, limit: 200 )) diff --git a/Sources/Infrastructure/Apps/AssetLibrary/Placements/SDKAssetPlacementRepository.swift b/Sources/Infrastructure/Apps/AssetLibrary/Placements/SDKAssetPlacementRepository.swift index def8e357..3ace294d 100644 --- a/Sources/Infrastructure/Apps/AssetLibrary/Placements/SDKAssetPlacementRepository.swift +++ b/Sources/Infrastructure/Apps/AssetLibrary/Placements/SDKAssetPlacementRepository.swift @@ -117,25 +117,25 @@ public struct SDKAssetPlacementRepository: AssetPlacementRepository, @unchecked switch surface { case .appStoreVersionLocalization: let r = APIEndpoint.v1.appStoreVersionLocalizations.id(localizationId).placements.get(parameters: .init( - filterPlacementType: placementType.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterPlacementType: sdkFilter(placementType), filterPlacementGroup: groups, sort: [.placementGroupPosition], limit: 200, include: [.image, .video] )) sdk = (r.path, r.query) case .experimentTreatmentLocalization: let r = APIEndpoint.v1.appStoreVersionExperimentTreatmentLocalizations.id(localizationId).placements.get(parameters: .init( - filterPlacementType: placementType.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterPlacementType: sdkFilter(placementType), filterPlacementGroup: groups, sort: [.placementGroupPosition], limit: 200, include: [.image, .video] )) sdk = (r.path, r.query) case .customProductPageLocalization: let r = APIEndpoint.v1.appCustomProductPageLocalizations.id(localizationId).placements.get(parameters: .init( - filterPlacementType: placementType.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterPlacementType: sdkFilter(placementType), filterPlacementGroup: groups, sort: [.placementGroupPosition], limit: 200, include: [.image, .video] )) sdk = (r.path, r.query) case .eventLocalization: let r = APIEndpoint.v1.appEventLocalizations.id(localizationId).placements.get(parameters: .init( - filterPlacementType: placementType.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterPlacementType: sdkFilter(placementType), filterPlacementGroup: groups, sort: [.placementGroupPosition], limit: 200, include: [.image, .video] )) sdk = (r.path, r.query) diff --git a/Sources/Infrastructure/Apps/AssetLibrary/Videos/SDKLibraryVideoRepository.swift b/Sources/Infrastructure/Apps/AssetLibrary/Videos/SDKLibraryVideoRepository.swift index f8efb908..14c46bdd 100644 --- a/Sources/Infrastructure/Apps/AssetLibrary/Videos/SDKLibraryVideoRepository.swift +++ b/Sources/Infrastructure/Apps/AssetLibrary/Videos/SDKLibraryVideoRepository.swift @@ -18,8 +18,8 @@ public struct SDKLibraryVideoRepository: LibraryVideoRepository, @unchecked Send category: AssetCategory? ) async throws -> [LibraryVideo] { let sdk = APIEndpoint.v1.appAssetLibraries.id(libraryId).videos.get(parameters: .init( - filterCategory: category.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, - filterState: state.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterCategory: sdkFilter(category), + filterState: sdkFilter(state), filterID: videoId.map { [$0] }, limit: 200 )) diff --git a/Sources/Infrastructure/Shared/SDKFilter.swift b/Sources/Infrastructure/Shared/SDKFilter.swift new file mode 100644 index 00000000..70cb20fc --- /dev/null +++ b/Sources/Infrastructure/Shared/SDKFilter.swift @@ -0,0 +1,10 @@ +/// Turns an optional domain enum into the one-element SDK filter array a query parameter takes, +/// or `nil` when there is no value (or the SDK has no matching case). +/// +/// The SDK type comes from the parameter being filled. Spelled as a generic function because +/// Swift 6.2 can't infer `.init(rawValue:)` through `value.flatMap { .init(…) }.map { [$0] }`. +func sdkFilter( + _ value: DomainValue? +) -> [SDKValue]? where SDKValue.RawValue == String, DomainValue.RawValue == String { + value.flatMap { SDKValue(rawValue: $0.rawValue) }.map { [$0] } +} diff --git a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift index 43b62ba4..094d6a1b 100644 --- a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift +++ b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift @@ -1278,6 +1278,139 @@ struct RESTRoutesTests { #expect(output.contains("/api/v1/apps/1234567890/builds")) } + // MARK: - Performance metrics & diagnostics + + @Test func `should link an app's metrics to the app's perf-metrics over REST`() async throws { + let repo = MockPerfMetricsRepository() + given(repo).listAppMetrics(appId: .any, metricType: .any).willReturn([ + PerformanceMetric( + id: "app-1-LAUNCH-launchTime", parentId: "app-1", parentType: .app, + category: .launch, metricIdentifier: "launchTime" + ), + ]) + + let output = try await PerfMetricsList.parse(["--app-id", "app-1", "--pretty"]) + .execute(repo: repo, affordanceMode: .rest) + + #expect(output.replacingOccurrences(of: "\\/", with: "/") == """ + { + "data" : [ + { + "_links" : { + "listAppMetrics" : { + "href" : "/api/v1/apps/app-1/perf-metrics", + "method" : "GET" + } + }, + "category" : "LAUNCH", + "id" : "app-1-LAUNCH-launchTime", + "metricIdentifier" : "launchTime", + "parentId" : "app-1", + "parentType" : "app" + } + ] + } + """) + } + + @Test func `should link a build's metrics to the build's perf-metrics over REST`() async throws { + let repo = MockPerfMetricsRepository() + given(repo).listBuildMetrics(buildId: .any, metricType: .any).willReturn([ + PerformanceMetric( + id: "build-1-HANG-hangRate", parentId: "build-1", parentType: .build, + category: .hang, metricIdentifier: "hangRate" + ), + ]) + + let output = try await PerfMetricsList.parse(["--build-id", "build-1", "--pretty"]) + .execute(repo: repo, affordanceMode: .rest) + + #expect(output.replacingOccurrences(of: "\\/", with: "/") == """ + { + "data" : [ + { + "_links" : { + "listBuildMetrics" : { + "href" : "/api/v1/builds/build-1/perf-metrics", + "method" : "GET" + } + }, + "category" : "HANG", + "id" : "build-1-HANG-hangRate", + "metricIdentifier" : "hangRate", + "parentId" : "build-1", + "parentType" : "build" + } + ] + } + """) + } + + @Test func `should link a diagnostic signature to its logs and its build's diagnostics over REST`() async throws { + let repo = MockDiagnosticsRepository() + given(repo).listSignatures(buildId: .any, diagnosticType: .any).willReturn([ + DiagnosticSignatureInfo( + id: "sig-1", buildId: "build-1", diagnosticType: .hangs, + signature: "main thread hang", weight: 45.2 + ), + ]) + + let output = try await DiagnosticsList.parse(["--build-id", "build-1", "--pretty"]) + .execute(repo: repo, affordanceMode: .rest) + + #expect(output.replacingOccurrences(of: "\\/", with: "/") == """ + { + "data" : [ + { + "_links" : { + "listLogs" : { + "href" : "/api/v1/diagnostics/sig-1/logs", + "method" : "GET" + }, + "listSignatures" : { + "href" : "/api/v1/builds/build-1/diagnostics", + "method" : "GET" + } + }, + "buildId" : "build-1", + "diagnosticType" : "HANGS", + "id" : "sig-1", + "signature" : "main thread hang", + "weight" : 45.2 + } + ] + } + """) + } + + @Test func `should link a diagnostic log back to its signature's logs over REST`() async throws { + let repo = MockDiagnosticsRepository() + given(repo).listLogs(signatureId: .any).willReturn([ + DiagnosticLogEntry(id: "sig-1-0-0", signatureId: "sig-1", event: "hang"), + ]) + + let output = try await DiagnosticLogsList.parse(["--signature-id", "sig-1", "--pretty"]) + .execute(repo: repo, affordanceMode: .rest) + + #expect(output.replacingOccurrences(of: "\\/", with: "/") == """ + { + "data" : [ + { + "_links" : { + "listLogs" : { + "href" : "/api/v1/diagnostics/sig-1/logs", + "method" : "GET" + } + }, + "event" : "hang", + "id" : "sig-1-0-0", + "signatureId" : "sig-1" + } + ] + } + """) + } + // MARK: - Game Center @Test func `should link a game's detail, achievements, leaderboards and blocked players over REST`() async throws { diff --git a/Tests/DomainTests/Apps/Performance/DiagnosticLogEntryTests.swift b/Tests/DomainTests/Apps/Performance/DiagnosticLogEntryTests.swift index ba04113c..0b9ddbf1 100644 --- a/Tests/DomainTests/Apps/Performance/DiagnosticLogEntryTests.swift +++ b/Tests/DomainTests/Apps/Performance/DiagnosticLogEntryTests.swift @@ -47,4 +47,31 @@ struct DiagnosticLogEntryTests { #expect(!json.contains("appVersion")) #expect(!json.contains("callStackSummary")) } + + // MARK: - REST + + @Test func `should link a log back to its signature's logs over REST`() { + let entry = MockRepositoryFactory.makeDiagnosticLogEntry(signatureId: "sig-1") + #expect(entry.apiLinks == [ + "listLogs": APILink(href: "/api/v1/diagnostics/sig-1/logs", method: "GET"), + ]) + } + + // MARK: - Table + + @Test func `should show id, bundle id, version, OS, device and event in a table`() { + let entry = MockRepositoryFactory.makeDiagnosticLogEntry( + id: "log-1", bundleId: "com.example.app", appVersion: "2.0", + osVersion: "iOS 17.0", deviceType: "iPhone15,2", event: "hang" + ) + #expect(DiagnosticLogEntry.tableHeaders == ["ID", "Bundle ID", "Version", "OS", "Device", "Event"]) + #expect(entry.tableRow == ["log-1", "com.example.app", "2.0", "iOS 17.0", "iPhone15,2", "hang"]) + } + + @Test func `should show dashes in a table when a log has no metadata`() { + let entry = MockRepositoryFactory.makeDiagnosticLogEntry( + id: "log-1", bundleId: nil, appVersion: nil, osVersion: nil, deviceType: nil, event: nil + ) + #expect(entry.tableRow == ["log-1", "-", "-", "-", "-", "-"]) + } } diff --git a/Tests/DomainTests/Apps/Performance/DiagnosticSignatureInfoTests.swift b/Tests/DomainTests/Apps/Performance/DiagnosticSignatureInfoTests.swift index a6d13da1..045fe4ee 100644 --- a/Tests/DomainTests/Apps/Performance/DiagnosticSignatureInfoTests.swift +++ b/Tests/DomainTests/Apps/Performance/DiagnosticSignatureInfoTests.swift @@ -52,4 +52,31 @@ struct DiagnosticSignatureInfoTests { let json = String(data: data, encoding: .utf8)! #expect(json.contains("\"insightDirection\":\"UP\"")) } + + // MARK: - REST + + @Test func `should link a signature to its logs and its build's diagnostics over REST`() { + let sig = MockRepositoryFactory.makeDiagnosticSignatureInfo(id: "sig-1", buildId: "build-1") + #expect(sig.apiLinks == [ + "listLogs": APILink(href: "/api/v1/diagnostics/sig-1/logs", method: "GET"), + "listSignatures": APILink(href: "/api/v1/builds/build-1/diagnostics", method: "GET"), + ]) + } + + // MARK: - Table + + @Test func `should show id, type, signature, weight and trend in a table`() { + let sig = MockRepositoryFactory.makeDiagnosticSignatureInfo( + id: "sig-1", diagnosticType: .hangs, signature: "main thread hang", weight: 45.2, insightDirection: "UP" + ) + #expect(DiagnosticSignatureInfo.tableHeaders == ["ID", "Type", "Signature", "Weight", "Trend"]) + #expect(sig.tableRow == ["sig-1", "HANGS", "main thread hang", "45.2", "UP"]) + } + + @Test func `should show a dash for trend in a table when App Store Connect gives no direction`() { + let sig = MockRepositoryFactory.makeDiagnosticSignatureInfo( + id: "sig-1", diagnosticType: .launches, signature: "slow launch", weight: 10, insightDirection: nil + ) + #expect(sig.tableRow == ["sig-1", "LAUNCHES", "slow launch", "10.0", "-"]) + } } diff --git a/Tests/DomainTests/Apps/Performance/PerfPowerMetricTests.swift b/Tests/DomainTests/Apps/Performance/PerfPowerMetricTests.swift index c17a608d..99625774 100644 --- a/Tests/DomainTests/Apps/Performance/PerfPowerMetricTests.swift +++ b/Tests/DomainTests/Apps/Performance/PerfPowerMetricTests.swift @@ -99,4 +99,37 @@ struct PerformanceMetricTests { #expect(PerfMetricParentType.app.rawValue == "app") #expect(PerfMetricParentType.build.rawValue == "build") } + + // MARK: - REST + + @Test func `should link app metrics to the app's perf-metrics over REST`() { + let metric = MockRepositoryFactory.makePerfPowerMetric(parentId: "app-1", parentType: .app) + #expect(metric.apiLinks == [ + "listAppMetrics": APILink(href: "/api/v1/apps/app-1/perf-metrics", method: "GET"), + ]) + } + + @Test func `should link build metrics to the build's perf-metrics over REST`() { + let metric = MockRepositoryFactory.makePerfPowerMetric(parentId: "build-1", parentType: .build) + #expect(metric.apiLinks == [ + "listBuildMetrics": APILink(href: "/api/v1/builds/build-1/perf-metrics", method: "GET"), + ]) + } + + // MARK: - Table + + @Test func `should show id, category, metric, value, unit and goal in a table`() { + let metric = MockRepositoryFactory.makePerfPowerMetric( + id: "m-1", category: .launch, metricIdentifier: "launchTime", unit: "s", latestValue: 1.5, goalValue: 1.0 + ) + #expect(PerformanceMetric.tableHeaders == ["ID", "Category", "Metric", "Value", "Unit", "Goal"]) + #expect(metric.tableRow == ["m-1", "LAUNCH", "launchTime", "1.5", "s", "1.0"]) + } + + @Test func `should show dashes in a table when a metric has no value, unit or goal`() { + let metric = MockRepositoryFactory.makePerfPowerMetric( + id: "m-1", category: .hang, metricIdentifier: "hangRate", unit: nil, latestValue: nil, goalValue: nil + ) + #expect(metric.tableRow == ["m-1", "HANG", "hangRate", "-", "-", "-"]) + } } diff --git a/docs/features/performance/README.md b/docs/features/performance/README.md index a4793538..54850005 100644 --- a/docs/features/performance/README.md +++ b/docs/features/performance/README.md @@ -124,8 +124,12 @@ Each log entry has device, OS and app version, and a `callStackSummary` of the t | CLI | REST | |---|---| | `asc perf-overview get --app-id [--device-type ]` | `GET /api/v1/apps//perf-overview[?device-type=]` | +| `asc perf-metrics list --app-id [--metric-type ]` | `GET /api/v1/apps//perf-metrics[?metric-type=]` | +| `asc perf-metrics list --build-id [--metric-type ]` | `GET /api/v1/builds//perf-metrics[?metric-type=]` | +| `asc diagnostics list --build-id [--diagnostic-type ]` | `GET /api/v1/builds//diagnostics[?diagnostic-type=]` | +| `asc diagnostic-logs list --signature-id ` | `GET /api/v1/diagnostics//logs` | -Every app's `_links` include `getPerfOverview`. +Every app's `_links` include `getPerfOverview`. Metrics link `listAppMetrics` or `listBuildMetrics`; a signature links `listLogs` and `listSignatures`; a log links `listLogs`. ## Gotchas @@ -133,7 +137,6 @@ Every app's `_links` include `getPerfOverview`. - Diagnostics are per build only; there is no app-level `diagnostics list`. - Metric and log IDs are synthetic (`{parentId}-{category}-{metric}`, `{signatureId}-{product}-{log}`); they are stable for display but are not Apple IDs. - `--device-type` is passed to App Store Connect as-is; it echoes back as `deviceType`. -- `perf-overview` is the only one of these commands on REST so far; `perf-metrics`, `diagnostics` and `diagnostic-logs` are CLI-only. ## See also