diff --git a/.github/actions/generate-build-metadata/action.yml b/.github/actions/generate-build-metadata/action.yml new file mode 100644 index 0000000..71d0cf6 --- /dev/null +++ b/.github/actions/generate-build-metadata/action.yml @@ -0,0 +1,45 @@ +name: Generate build metadata +description: Generate the Swift source containing release build metadata. + +inputs: + version: + description: Version string to embed + required: true + commit-sha: + description: Commit SHA to embed + required: true + build-date: + description: Build date to embed + required: true + +runs: + using: composite + steps: + - name: Generate build metadata + shell: bash + working-directory: ${{ github.workspace }} + env: + VERSION: ${{ inputs.version }} + COMMIT_SHA: ${{ inputs.commit-sha }} + BUILD_DATE: ${{ inputs.build-date }} + run: | + swift_string() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' + } + + VERSION=$(swift_string "$VERSION") + COMMIT_SHA=$(swift_string "$COMMIT_SHA") + BUILD_DATE=$(swift_string "$BUILD_DATE") + + cat > Sources/CLI/main/BuildMetadata.swift < Sources/CLI/main/BuildMetadata.swift < "$RUNNER_TEMP/string.json" + jq -e '.metadata.title == "String"' "$RUNNER_TEMP/string.json" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: cli-${{ matrix.platform }} + path: dist/apple-docs-${{ matrix.platform }} + if-no-files-found: error + retention-days: 1 + sign-and-notarize-darwin: name: Sign and Notarize macOS Binaries if: github.event_name != 'pull_request' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1503bd6..80e5d39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,6 +145,8 @@ jobs: files: | dist/apple-docs-darwin-amd64 dist/apple-docs-darwin-arm64 + dist/apple-docs-linux-amd64 + dist/apple-docs-linux-arm64 dist/checksums.txt draft: false prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }} @@ -153,7 +155,7 @@ jobs: name: Trigger Release Publisher needs: [prepare, release] # Dispatch the techprimate/publisher workflow that publishes this GitHub - # Release through packages.techprimate.app. Replaces the in-repo Homebrew tap + # Release through packages.techprimate.com. Replaces the in-repo Homebrew tap # automation. if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1dabf98..9dbc2e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,3 +29,19 @@ jobs: - name: Make Test run: make test + + test-linux: + name: Test Linux + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Swift + uses: swift-actions/setup-swift@v3 + with: + swift-version: "6.3.3" + + - name: Make Test + run: make test diff --git a/Makefile b/Makefile index bce758f..05c197b 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,19 @@ run: test: swift test +## Run all tests in a Linux container +# +# Uses a Docker volume for SwiftPM build output so Linux artifacts do not conflict +# with the host build directory. +.PHONY: test-linux +test-linux: + docker run --rm \ + --mount "type=bind,source=$(CURDIR),target=/workspace,readonly" \ + --volume "apple-docs-cli-linux-build:/workspace/.build" \ + --workdir /workspace \ + swift:6.3.3 \ + swift test --disable-automatic-resolution + ## Run live CLI integration tests # # Builds the release executable and runs network-dependent command tests against Apple documentation. diff --git a/Package.swift b/Package.swift index eb7faff..03b8fb6 100644 --- a/Package.swift +++ b/Package.swift @@ -2,15 +2,17 @@ import PackageDescription -let package = Package( - name: "apple-docs-cli", - platforms: [.macOS(.v13)], - products: [ - .executable(name: "apple-docs", targets: ["CLI"]) - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.8.2"), - .package(url: "https://github.com/apple/swift-log.git", exact: "1.15.1"), +var packageDependencies: [Package.Dependency] = [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.8.2"), + .package(url: "https://github.com/apple/swift-log.git", exact: "1.15.1"), +] +var cliDependencies: [Target.Dependency] = [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), +] + +#if os(macOS) + packageDependencies += [ .package( url: "https://github.com/getsentry/sentry-apple-swift-log.git", exact: "9.28.0", @@ -21,16 +23,25 @@ let package = Package( exact: "9.28.0", traits: ["NoUIFramework"] ), + ] + cliDependencies += [ + .product(name: "SentrySwiftLog", package: "sentry-apple-swift-log"), + .product(name: "SentrySPM", package: "sentry-cocoa"), + ] +#endif + +let package = Package( + name: "apple-docs-cli", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "apple-docs", targets: ["CLI"]) ], + dependencies: packageDependencies, targets: [ .executableTarget( name: "CLI", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log"), - .product(name: "SentrySwiftLog", package: "sentry-apple-swift-log"), - .product(name: "SentrySPM", package: "sentry-cocoa"), - ]), + dependencies: cliDependencies + ), .testTarget(name: "CLITests", dependencies: ["CLI"]), .testTarget(name: "CLIIntegrationTests"), ], diff --git a/README.md b/README.md index 12f52dc..ade22af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # apple-docs-cli -`apple-docs-cli` is a stateless macOS CLI for retrieving Apple Developer documentation for known API types. It fetches Apple’s DocC JSON and renders a concise terminal view or returns the raw document for further processing. +`apple-docs-cli` is a stateless macOS and Linux CLI for retrieving Apple Developer documentation for known API types. It fetches Apple’s DocC JSON and renders a concise terminal view or returns the raw document for further processing. ## Type documentation @@ -104,6 +104,12 @@ make test make analyze ``` +Run the test suite in a pinned Linux Swift container: + +```bash +make test-linux +``` + Format Swift with `swift format` and JSON, YAML, Markdown, and TOML with dprint: ```bash diff --git a/Sources/CLI/cache/DocumentationCache.swift b/Sources/CLI/cache/DocumentationCache.swift index 4d67e45..f9f37b1 100644 --- a/Sources/CLI/cache/DocumentationCache.swift +++ b/Sources/CLI/cache/DocumentationCache.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + #if DEBUG protocol DocumentationCache { var currentDiskUsage: Int { get } diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index 6712f07..07ee2b1 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + #if DEBUG protocol AppleDocumentationClient: Sendable { func fetchType(named name: String, technology: String) async throws -> TypeDocumentationDocument diff --git a/Sources/CLI/client/DocumentationTypeCatalogClient.swift b/Sources/CLI/client/DocumentationTypeCatalogClient.swift index 15ac033..121afb9 100644 --- a/Sources/CLI/client/DocumentationTypeCatalogClient.swift +++ b/Sources/CLI/client/DocumentationTypeCatalogClient.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + #if DEBUG protocol DocumentationTypeCatalogClient: Sendable { func fetchTypes(technology: String) async throws -> [DocumentationType] diff --git a/Sources/CLI/client/DocumentationTypeSearchClient.swift b/Sources/CLI/client/DocumentationTypeSearchClient.swift index 2961efa..dc49f96 100644 --- a/Sources/CLI/client/DocumentationTypeSearchClient.swift +++ b/Sources/CLI/client/DocumentationTypeSearchClient.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + #if DEBUG protocol DocumentationTypeSearchClient: Sendable { func searchTypes(query: String, technology: String) async throws -> [DocumentationType] diff --git a/Sources/CLI/client/TechnologyCatalogClient.swift b/Sources/CLI/client/TechnologyCatalogClient.swift index 8b7e7ca..4c6d65a 100644 --- a/Sources/CLI/client/TechnologyCatalogClient.swift +++ b/Sources/CLI/client/TechnologyCatalogClient.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + #if DEBUG protocol TechnologyCatalogClient: Sendable { func fetchTechnologies() async throws -> [Technology] diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift index 0aaad58..0f42069 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift @@ -1,6 +1,9 @@ import ArgumentParser import Logging -@preconcurrency import SentrySwift + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift +#endif struct AgentSkillsGetCommand: ParsableCommand { private static let logger = Logger( @@ -16,34 +19,36 @@ struct AgentSkillsGetCommand: ParsableCommand { var name: String mutating func run() throws { - if SentrySDK.isEnabled { - let context = SentryCommandContext.agentSkillsGet - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + let context = SentryCommandContext.agentSkillsGet + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } + #endif guard let skill = BundledAgentSkills.skill(named: name) else { throw ValidationError("Unknown bundled Agent Skill '\(name)'.") diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift index 56a10d8..6156aad 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift @@ -1,6 +1,9 @@ import ArgumentParser import Logging -@preconcurrency import SentrySwift + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift +#endif struct AgentSkillsListCommand: ParsableCommand { private static let logger = Logger( @@ -13,34 +16,36 @@ struct AgentSkillsListCommand: ParsableCommand { ) mutating func run() throws { - if SentrySDK.isEnabled { - let context = SentryCommandContext.agentSkillsList - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + let context = SentryCommandContext.agentSkillsList + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } + #endif for skill in BundledAgentSkills.all { print("\(skill.name)\t\(skill.shortDescription)") diff --git a/Sources/CLI/cmd/cache/CacheCleanCommand.swift b/Sources/CLI/cmd/cache/CacheCleanCommand.swift index 619df65..35ef7d1 100644 --- a/Sources/CLI/cmd/cache/CacheCleanCommand.swift +++ b/Sources/CLI/cmd/cache/CacheCleanCommand.swift @@ -1,6 +1,9 @@ import ArgumentParser import Logging -@preconcurrency import SentrySwift + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift +#endif struct CacheCleanCommand: ParsableCommand { private static let logger = Logger( @@ -13,34 +16,36 @@ struct CacheCleanCommand: ParsableCommand { ) mutating func run() throws { - if SentrySDK.isEnabled { - let context = SentryCommandContext.cacheClean - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") - } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + let context = SentryCommandContext.cacheClean + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } + #endif let result = CacheCleanCommandRunner( cache: Dependencies.documentationCache diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift index 230a279..aad6ffb 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift @@ -1,6 +1,9 @@ import ArgumentParser import Logging -@preconcurrency import SentrySwift + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift +#endif struct TechnologiesListCommand: AsyncParsableCommand { private static let logger = Logger( @@ -17,45 +20,49 @@ struct TechnologiesListCommand: AsyncParsableCommand { mutating func run() async throws { let context = SentryCommandContext.technologiesList(json: json) - if SentrySDK.isEnabled { - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } + #endif let result = try await TechnologiesListCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.technologyListRenderer(json: json) ).run() - if SentrySDK.isEnabled { - SentrySDK.metrics.gauge( - key: "apple_docs.technology.catalog.count", - value: Double(result.technologyCount), - attributes: context.metricAttributes - ) - } + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + SentrySDK.metrics.gauge( + key: "apple_docs.technology.catalog.count", + value: Double(result.technologyCount), + attributes: context.metricAttributes + ) + } + #endif print(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesListCommand.swift b/Sources/CLI/cmd/types/TypesListCommand.swift index cec5f0a..1110729 100644 --- a/Sources/CLI/cmd/types/TypesListCommand.swift +++ b/Sources/CLI/cmd/types/TypesListCommand.swift @@ -1,6 +1,9 @@ import ArgumentParser import Logging -@preconcurrency import SentrySwift + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift +#endif struct TypesListCommand: AsyncParsableCommand { private static let logger = Logger( @@ -23,45 +26,49 @@ struct TypesListCommand: AsyncParsableCommand { technology: technology, json: json ) - if SentrySDK.isEnabled { - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } + #endif let result = try await TypesListCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.documentationTypeListRenderer(json: json) ).run(technology: technology) - if SentrySDK.isEnabled { - SentrySDK.metrics.gauge( - key: "apple_docs.type.catalog.count", - value: Double(result.typeCount), - attributes: context.metricAttributes - ) - } + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + SentrySDK.metrics.gauge( + key: "apple_docs.type.catalog.count", + value: Double(result.typeCount), + attributes: context.metricAttributes + ) + } + #endif print(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index 4e53f01..56a95d6 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -1,6 +1,9 @@ import ArgumentParser import Logging -@preconcurrency import SentrySwift + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift +#endif struct TypesSearchCommand: AsyncParsableCommand { private static let logger = Logger( @@ -27,45 +30,49 @@ struct TypesSearchCommand: AsyncParsableCommand { technology: technology, json: json ) - if SentrySDK.isEnabled { - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } + #endif let result = try await TypesSearchCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.documentationTypeListRenderer(json: json) ).run(query: query, technology: technology) - if SentrySDK.isEnabled { - SentrySDK.metrics.distribution( - key: "apple_docs.type.search.result.count", - value: Double(result.matchCount), - attributes: context.metricAttributes - ) - } + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + SentrySDK.metrics.distribution( + key: "apple_docs.type.search.result.count", + value: Double(result.matchCount), + attributes: context.metricAttributes + ) + } + #endif print(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesViewCommand.swift b/Sources/CLI/cmd/types/TypesViewCommand.swift index c69abb4..86eb30c 100644 --- a/Sources/CLI/cmd/types/TypesViewCommand.swift +++ b/Sources/CLI/cmd/types/TypesViewCommand.swift @@ -1,6 +1,9 @@ import ArgumentParser import Logging -@preconcurrency import SentrySwift + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift +#endif struct TypesViewCommand: AsyncParsableCommand { private static let logger = Logger( @@ -27,61 +30,67 @@ struct TypesViewCommand: AsyncParsableCommand { technology: technology, json: json ) - if SentrySDK.isEnabled { - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") - } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } + #endif let result = try await TypesViewCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.documentationRenderer(json: json) ).run(name: name, technology: technology) - if SentrySDK.isEnabled { - recordPopularityMetrics() - SentrySDK.metrics.distribution( - key: "apple_docs.response.size", - value: Double(result.responseByteCount), - unit: .byte, - attributes: context.metricAttributes - ) - } + #if canImport(SentrySwift) + if SentrySDK.isEnabled { + recordPopularityMetrics() + SentrySDK.metrics.distribution( + key: "apple_docs.response.size", + value: Double(result.responseByteCount), + unit: .byte, + attributes: context.metricAttributes + ) + } + #endif print(result.output) } - private func recordPopularityMetrics() { - SentrySDK.metrics.count( - key: "apple_docs.technology.requested", - attributes: ["apple_docs.technology": technology] - ) - SentrySDK.metrics.count( - key: "apple_docs.type.requested", - attributes: [ - "apple_docs.technology": technology, - "apple_docs.type": name, - ] - ) - } + #if canImport(SentrySwift) + private func recordPopularityMetrics() { + SentrySDK.metrics.count( + key: "apple_docs.technology.requested", + attributes: ["apple_docs.technology": technology] + ) + SentrySDK.metrics.count( + key: "apple_docs.type.requested", + attributes: [ + "apple_docs.technology": technology, + "apple_docs.type": name, + ] + ) + } + #endif } diff --git a/Sources/CLI/http/HTTPDataTransport.swift b/Sources/CLI/http/HTTPDataTransport.swift index d3276a2..cc9b501 100644 --- a/Sources/CLI/http/HTTPDataTransport.swift +++ b/Sources/CLI/http/HTTPDataTransport.swift @@ -1,11 +1,23 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + #if DEBUG protocol HTTPDataTransport: Sendable { func data(from url: URL) async throws -> (Data, URLResponse) } - extension URLSession: HTTPDataTransport {} + #if canImport(FoundationNetworking) + extension URLSession: HTTPDataTransport { + func data(from url: URL) async throws -> (Data, URLResponse) { + try await data(from: url, delegate: nil) + } + } + #else + extension URLSession: HTTPDataTransport {} + #endif typealias DefaultAppleDocumentationClientDependencies = HTTPDataTransport #else diff --git a/Sources/CLI/main/AppleDocs.swift b/Sources/CLI/main/AppleDocs.swift index 65cf406..63ace2a 100644 --- a/Sources/CLI/main/AppleDocs.swift +++ b/Sources/CLI/main/AppleDocs.swift @@ -1,8 +1,11 @@ import ArgumentParser import Foundation import Logging -@preconcurrency import SentrySwift -import SentrySwiftLog + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift + import SentrySwiftLog +#endif @main enum AppleDocs { @@ -10,21 +13,7 @@ enum AppleDocs { @MainActor static func main() async { - let telemetryEnabled = SentryConfiguration.isEnabled( - environment: ProcessInfo.processInfo.environment - ) - if telemetryEnabled { - SentrySDK.start { options in - SentryConfiguration.configure(options) - } - LoggingSystem.bootstrap { _ in - SentryLogHandler(logLevel: .info) - } - } else { - LoggingSystem.bootstrap { _ in - SwiftLogNoOpLogHandler() - } - } + let telemetryEnabled = configureTelemetry() do { var command = try await CLI.asyncParseAsRoot() @@ -33,14 +22,57 @@ enum AppleDocs { } else { try command.run() } - if telemetryEnabled { - SentrySDK.span?.status = .ok - Self.logger.info("CLI command completed") - SentrySDK.span?.finish() - SentrySDK.flush(timeout: 2) - } + finishTelemetry(enabled: telemetryEnabled) } catch { - if telemetryEnabled, let span = SentrySDK.span { + captureTelemetry(error, enabled: telemetryEnabled) + CLI.exit(withError: error) + } + } + + private static func configureTelemetry() -> Bool { + #if canImport(SentrySwift) + let enabled = SentryConfiguration.isEnabled( + environment: ProcessInfo.processInfo.environment + ) + if enabled { + SentrySDK.start { options in + SentryConfiguration.configure(options) + } + LoggingSystem.bootstrap { _ in + SentryLogHandler(logLevel: .info) + } + } else { + LoggingSystem.bootstrap { _ in + SwiftLogNoOpLogHandler() + } + } + return enabled + #else + LoggingSystem.bootstrap { _ in + SwiftLogNoOpLogHandler() + } + return false + #endif + } + + private static func finishTelemetry(enabled: Bool) { + #if canImport(SentrySwift) + guard enabled else { + return + } + SentrySDK.span?.status = .ok + Self.logger.info("CLI command completed") + SentrySDK.span?.finish() + SentrySDK.flush(timeout: 2) + #endif + } + + private static func captureTelemetry(_ error: Error, enabled: Bool) { + #if canImport(SentrySwift) + guard enabled else { + return + } + if let span = SentrySDK.span { // Lookup misses are actionable CLI outcomes, not application reliability failures. let expected = error is ValidationError || SentryConfiguration.isExpected(error: error) span.status = expected ? .invalidArgument : .internalError @@ -52,10 +84,7 @@ enum AppleDocs { } span.finish() } - if telemetryEnabled { - SentrySDK.flush(timeout: 2) - } - CLI.exit(withError: error) - } + SentrySDK.flush(timeout: 2) + #endif } } diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index 92f9b92..4903862 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + enum Dependencies { static let httpCache: URLCache? = { guard @@ -14,11 +18,19 @@ enum Dependencies { "com.techprimate.apple-docs", isDirectory: true ) - return URLCache( - memoryCapacity: 16_000_000, - diskCapacity: 1_000_000_000, - directory: cacheDirectory - ) + #if canImport(FoundationNetworking) + return URLCache( + memoryCapacity: 16_000_000, + diskCapacity: 1_000_000_000, + diskPath: cacheDirectory.path + ) + #else + return URLCache( + memoryCapacity: 16_000_000, + diskCapacity: 1_000_000_000, + directory: cacheDirectory + ) + #endif }() static let httpDataTransport: URLSession = { diff --git a/Sources/CLI/telemetry/SentryCommandContext.swift b/Sources/CLI/telemetry/SentryCommandContext.swift index d129423..7dfedce 100644 --- a/Sources/CLI/telemetry/SentryCommandContext.swift +++ b/Sources/CLI/telemetry/SentryCommandContext.swift @@ -1,5 +1,8 @@ import Logging -@preconcurrency import SentrySwift + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift +#endif struct SentryCommandContext: Equatable, Sendable { let command: String @@ -104,16 +107,18 @@ struct SentryCommandContext: Equatable, Sendable { return metadata } - var metricAttributes: [String: any SentryAttributeValue] { - var attributes: [String: any SentryAttributeValue] = [ - "cli.command": command - ] - if let technology { - attributes["apple_docs.technology"] = technology - } - if let typeName { - attributes["apple_docs.type"] = typeName + #if canImport(SentrySwift) + var metricAttributes: [String: any SentryAttributeValue] { + var attributes: [String: any SentryAttributeValue] = [ + "cli.command": command + ] + if let technology { + attributes["apple_docs.technology"] = technology + } + if let typeName { + attributes["apple_docs.type"] = typeName + } + return attributes } - return attributes - } + #endif } diff --git a/Sources/CLI/telemetry/SentryConfiguration.swift b/Sources/CLI/telemetry/SentryConfiguration.swift index 30cbf7d..78d0e65 100644 --- a/Sources/CLI/telemetry/SentryConfiguration.swift +++ b/Sources/CLI/telemetry/SentryConfiguration.swift @@ -1,189 +1,194 @@ import Foundation -@preconcurrency import SentrySwift + +#if canImport(SentrySwift) + @preconcurrency import SentrySwift +#endif protocol ExpectedCommandError: Error { var isExpected: Bool { get } } -struct SentryConfiguration { - private static let allowedBreadcrumbDataKeys: Set = [ - "apple_docs.technology", - "apple_docs.type", - "cli.command", - "cli.output_json", - ] - private static let allowedContextKeys: Set = [ - "cli", - "os", - "runtime", - "trace", - ] - private static let allowedLogAttributes: Set = [ - "environment", - "release", - "sentry.origin", - "sentry.sdk.name", - "sentry.sdk.version", - "swift-log.apple_docs.technology", - "swift-log.apple_docs.type", - "swift-log.cli.command", - "swift-log.cli.output_json", - "swift-log.level", - "swift-log.source", - ] - private static let allowedLogBodies: Set = [ - "CLI command completed", - "CLI command failed", - "CLI command rejected", - "CLI command started", - ] - private static let allowedMetricAttributes: Set = [ - "apple_docs.technology", - "apple_docs.type", - "cli.command", - "environment", - "release", - ] - private static let allowedMetricNames: Set = [ - "apple_docs.response.size", - "apple_docs.technology.catalog.count", - "apple_docs.type.catalog.count", - "apple_docs.type.search.result.count", - "apple_docs.technology.requested", - "apple_docs.type.requested", - ] - static let breadcrumbCategory = "cli.command" - static let dsn = - "https://927b98fc26175a0d5dda2124b9b471dd@o188824.ingest.us.sentry.io/4512051116376064" +#if canImport(SentrySwift) + struct SentryConfiguration { + private static let allowedBreadcrumbDataKeys: Set = [ + "apple_docs.technology", + "apple_docs.type", + "cli.command", + "cli.output_json", + ] + private static let allowedContextKeys: Set = [ + "cli", + "os", + "runtime", + "trace", + ] + private static let allowedLogAttributes: Set = [ + "environment", + "release", + "sentry.origin", + "sentry.sdk.name", + "sentry.sdk.version", + "swift-log.apple_docs.technology", + "swift-log.apple_docs.type", + "swift-log.cli.command", + "swift-log.cli.output_json", + "swift-log.level", + "swift-log.source", + ] + private static let allowedLogBodies: Set = [ + "CLI command completed", + "CLI command failed", + "CLI command rejected", + "CLI command started", + ] + private static let allowedMetricAttributes: Set = [ + "apple_docs.technology", + "apple_docs.type", + "cli.command", + "environment", + "release", + ] + private static let allowedMetricNames: Set = [ + "apple_docs.response.size", + "apple_docs.technology.catalog.count", + "apple_docs.type.catalog.count", + "apple_docs.type.search.result.count", + "apple_docs.technology.requested", + "apple_docs.type.requested", + ] + static let breadcrumbCategory = "cli.command" + static let dsn = + "https://927b98fc26175a0d5dda2124b9b471dd@o188824.ingest.us.sentry.io/4512051116376064" - static func isEnabled(environment: [String: String]) -> Bool { - environment["TELEMETRY_DISABLED"]?.caseInsensitiveCompare("true") != .orderedSame - } + static func isEnabled(environment: [String: String]) -> Bool { + environment["TELEMETRY_DISABLED"]?.caseInsensitiveCompare("true") != .orderedSame + } - static func isExpected(error: Swift.Error) -> Bool { - (error as? any ExpectedCommandError)?.isExpected == true - } + static func isExpected(error: Swift.Error) -> Bool { + (error as? any ExpectedCommandError)?.isExpected == true + } - static func configure(_ options: Options) { - options.dsn = dsn - options.environment = BuildMetadata.environment - options.releaseName = BuildMetadata.sentryRelease - options.debug = false - configureErrorMonitoring(options) - configureTracing(options) - configureBreadcrumbs(options) - configureSignals(options) - configureFilters(options) - } + static func configure(_ options: Options) { + options.dsn = dsn + options.environment = BuildMetadata.environment + options.releaseName = BuildMetadata.sentryRelease + options.debug = false + configureErrorMonitoring(options) + configureTracing(options) + configureBreadcrumbs(options) + configureSignals(options) + configureFilters(options) + } - private static func configureErrorMonitoring(_ options: Options) { - options.sendDefaultPii = false - options.add(inAppInclude: "apple-docs") - options.attachStacktrace = true - options.enableCrashHandler = true - options.enableAppHangTracking = false - options.enableMemoryIntrospection = false - } + private static func configureErrorMonitoring(_ options: Options) { + options.sendDefaultPii = false + options.add(inAppInclude: "apple-docs") + options.attachStacktrace = true + options.enableCrashHandler = true + options.enableAppHangTracking = false + options.enableMemoryIntrospection = false + } - private static func configureTracing(_ options: Options) { - options.tracesSampleRate = 1.0 - options.enableAutoPerformanceTracing = false - options.enableNetworkTracking = false - options.enableFileIOTracing = false - options.enableCoreDataTracing = false - options.enableSwizzling = false - options.tracePropagationTargets = [] - } + private static func configureTracing(_ options: Options) { + options.tracesSampleRate = 1.0 + options.enableAutoPerformanceTracing = false + options.enableNetworkTracking = false + options.enableFileIOTracing = false + options.enableCoreDataTracing = false + options.enableSwizzling = false + options.tracePropagationTargets = [] + } - private static func configureBreadcrumbs(_ options: Options) { - options.enableAutoBreadcrumbTracking = false - options.enableNetworkBreadcrumbs = false - options.enableCaptureFailedRequests = false - options.maxBreadcrumbs = 10 - options.beforeBreadcrumb = { breadcrumb in - guard breadcrumb.category == breadcrumbCategory else { - return nil - } - breadcrumb.message = "CLI command invoked" - breadcrumb.type = "user" - if let data = breadcrumb.data { - for key in data.keys where !allowedBreadcrumbDataKeys.contains(key) { - breadcrumb.setData(value: nil, key: key) + private static func configureBreadcrumbs(_ options: Options) { + options.enableAutoBreadcrumbTracking = false + options.enableNetworkBreadcrumbs = false + options.enableCaptureFailedRequests = false + options.maxBreadcrumbs = 10 + options.beforeBreadcrumb = { breadcrumb in + guard breadcrumb.category == breadcrumbCategory else { + return nil + } + breadcrumb.message = "CLI command invoked" + breadcrumb.type = "user" + if let data = breadcrumb.data { + for key in data.keys where !allowedBreadcrumbDataKeys.contains(key) { + breadcrumb.setData(value: nil, key: key) + } } + return breadcrumb } - return breadcrumb } - } - private static func configureSignals(_ options: Options) { - options.enableLogs = true - options.enableMetrics = true - } + private static func configureSignals(_ options: Options) { + options.enableLogs = true + options.enableMetrics = true + } - private static func configureFilters(_ options: Options) { - options.beforeSend = { event in - event.user = nil - event.request = nil - event.serverName = nil - event.extra = nil - event.tags = nil - event.message = nil - event.error = nil - event.context = event.context?.filter { - allowedContextKeys.contains($0.key) - } - event.breadcrumbs = event.breadcrumbs?.filter { - $0.category == breadcrumbCategory + private static func configureFilters(_ options: Options) { + options.beforeSend = { event in + event.user = nil + event.request = nil + event.serverName = nil + event.extra = nil + event.tags = nil + event.message = nil + event.error = nil + event.context = event.context?.filter { + allowedContextKeys.contains($0.key) + } + event.breadcrumbs = event.breadcrumbs?.filter { + $0.category == breadcrumbCategory + } + for exception in event.exceptions ?? [] { + exception.value = "CLI command failed" + } + sanitizePaths(in: event) + return event } - for exception in event.exceptions ?? [] { - exception.value = "CLI command failed" + options.beforeSendSpan = { span in + span.operation == "console.command" ? span : nil } - sanitizePaths(in: event) - return event - } - options.beforeSendSpan = { span in - span.operation == "console.command" ? span : nil - } - options.beforeSendLog = { log in - guard allowedLogBodies.contains(log.body) else { - return nil + options.beforeSendLog = { log in + guard allowedLogBodies.contains(log.body) else { + return nil + } + log.attributes = log.attributes.filter { + allowedLogAttributes.contains($0.key) + } + return log } - log.attributes = log.attributes.filter { - allowedLogAttributes.contains($0.key) + options.beforeSendMetric = { metric in + guard allowedMetricNames.contains(metric.name) else { + return nil + } + var metric = metric + metric.attributes = metric.attributes.filter { + allowedMetricAttributes.contains($0.key) + } + return metric } - return log } - options.beforeSendMetric = { metric in - guard allowedMetricNames.contains(metric.name) else { - return nil + + private static func sanitizePaths(in event: Event) { + for debugImage in event.debugMeta ?? [] { + debugImage.codeFile = fileName(from: debugImage.codeFile) } - var metric = metric - metric.attributes = metric.attributes.filter { - allowedMetricAttributes.contains($0.key) + for exception in event.exceptions ?? [] { + sanitizePaths(in: exception.stacktrace) + } + for thread in event.threads ?? [] { + sanitizePaths(in: thread.stacktrace) } - return metric } - } - private static func sanitizePaths(in event: Event) { - for debugImage in event.debugMeta ?? [] { - debugImage.codeFile = fileName(from: debugImage.codeFile) - } - for exception in event.exceptions ?? [] { - sanitizePaths(in: exception.stacktrace) - } - for thread in event.threads ?? [] { - sanitizePaths(in: thread.stacktrace) + private static func sanitizePaths(in stacktrace: SentryStacktrace?) { + for frame in stacktrace?.frames ?? [] { + frame.package = fileName(from: frame.package) + } } - } - private static func sanitizePaths(in stacktrace: SentryStacktrace?) { - for frame in stacktrace?.frames ?? [] { - frame.package = fileName(from: frame.package) + private static func fileName(from path: String?) -> String? { + path.map { URL(fileURLWithPath: $0).lastPathComponent } } } - - private static func fileName(from path: String?) -> String? { - path.map { URL(fileURLWithPath: $0).lastPathComponent } - } -} +#endif diff --git a/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift b/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift index 3ce6efa..6539fb4 100644 --- a/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift @@ -3,6 +3,10 @@ import Testing @testable import CLI +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @Suite("Apple documentation client errors") struct AppleDocumentationClientErrorTests { @Test("maps a missing type to discovery guidance") diff --git a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift index ac07a9c..1e61a94 100644 --- a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift @@ -3,6 +3,10 @@ import Testing @testable import CLI +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @Suite("Apple documentation type search client") struct AppleDocumentationClientSearchTests { @Test("searches symbols across nested collection groups") diff --git a/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift b/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift index 5f08b87..b21eb62 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift @@ -3,6 +3,10 @@ import Testing @testable import CLI +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @Suite("Apple documentation technology client") struct AppleDocumentationClientTechnologyTests { @Test("fetches technologies from every catalog group") diff --git a/Tests/CLITests/client/AppleDocumentationClientTests.swift b/Tests/CLITests/client/AppleDocumentationClientTests.swift index f378bcb..6b05d84 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientTests.swift @@ -3,6 +3,10 @@ import Testing @testable import CLI +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @Suite("Apple documentation client") struct AppleDocumentationClientTests { @Test("requests and decodes a type documentation page") diff --git a/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift b/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift index e5e788b..4f61bcd 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift @@ -3,6 +3,10 @@ import Testing @testable import CLI +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @Suite("Apple documentation type catalog client") struct AppleDocumentationClientTypeListTests { @Test("lists direct symbols from a technology root document") diff --git a/Tests/CLITests/main/DependenciesTests.swift b/Tests/CLITests/main/DependenciesTests.swift index f89bda7..71ad6e1 100644 --- a/Tests/CLITests/main/DependenciesTests.swift +++ b/Tests/CLITests/main/DependenciesTests.swift @@ -3,6 +3,10 @@ import Testing @testable import CLI +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @Suite("Dependencies") struct DependenciesTests { @Test("uses a dedicated large documentation cache") diff --git a/Tests/CLITests/telemetry/SentryCommandContextTests.swift b/Tests/CLITests/telemetry/SentryCommandContextTests.swift index c00043f..c7963a2 100644 --- a/Tests/CLITests/telemetry/SentryCommandContextTests.swift +++ b/Tests/CLITests/telemetry/SentryCommandContextTests.swift @@ -2,136 +2,138 @@ import Testing @testable import CLI -@Suite("Sentry command context") -struct SentryCommandContextTests { - @Test("excludes the query from types search telemetry") - func excludesTypesSearchQuery() { - // -- Arrange -- - let expectedKeys = [ - "apple_docs.technology", - "cli.command", - "cli.output_json", - ] - - // -- Act -- - let context = SentryCommandContext.typesSearch( - technology: "SwiftUI", - json: true - ) - - // -- Assert -- - #expect(context.command == "types.search") - #expect(context.typeName == nil) - #expect(context.technology == "SwiftUI") - #expect(context.outputJSON == true) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) - #expect(context.logMetadata.keys.sorted() == expectedKeys) +#if canImport(SentrySwift) + @Suite("Sentry command context") + struct SentryCommandContextTests { + @Test("excludes the query from types search telemetry") + func excludesTypesSearchQuery() { + // -- Arrange -- + let expectedKeys = [ + "apple_docs.technology", + "cli.command", + "cli.output_json", + ] + + // -- Act -- + let context = SentryCommandContext.typesSearch( + technology: "SwiftUI", + json: true + ) + + // -- Assert -- + #expect(context.command == "types.search") + #expect(context.typeName == nil) + #expect(context.technology == "SwiftUI") + #expect(context.outputJSON == true) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("opts documentation identifiers into types view telemetry") + func includesTypesViewIdentifiers() { + // -- Arrange -- + let expectedKeys = [ + "apple_docs.technology", + "apple_docs.type", + "cli.command", + "cli.output_json", + ] + + // -- Act -- + let context = SentryCommandContext.typesView( + name: "MXHangDiagnostic", + technology: "MetricKit", + json: true + ) + + // -- Assert -- + #expect(context.command == "types.view") + #expect(context.typeName == "MXHangDiagnostic") + #expect(context.technology == "MetricKit") + #expect(context.outputJSON == true) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("opts technology and output mode into types list telemetry") + func includesTypesListContext() { + // -- Arrange -- + let expectedKeys = [ + "apple_docs.technology", + "cli.command", + "cli.output_json", + ] + + // -- Act -- + let context = SentryCommandContext.typesList( + technology: "SwiftData", + json: true + ) + + // -- Assert -- + #expect(context.command == "types.list") + #expect(context.typeName == nil) + #expect(context.technology == "SwiftData") + #expect(context.outputJSON == true) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("excludes the skill name from agent command telemetry") + func excludesAgentSkillName() { + // -- Arrange -- + let expectedKeys = ["cli.command"] + + // -- Act -- + let context = SentryCommandContext.agentSkillsGet + + // -- Assert -- + #expect(context.command == "agent.skills.get") + #expect(context.typeName == nil) + #expect(context.technology == nil) + #expect(context.outputJSON == nil) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("uses only the command name for cache clean telemetry") + func includesCacheCleanCommand() { + // -- Arrange -- + let expectedKeys = ["cli.command"] + + // -- Act -- + let context = SentryCommandContext.cacheClean + + // -- Assert -- + #expect(context.command == "cache.clean") + #expect(context.typeName == nil) + #expect(context.technology == nil) + #expect(context.outputJSON == nil) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("opts only output mode into technologies list telemetry") + func includesTechnologiesListOutputMode() { + // -- Arrange -- + let expectedAttributeKeys = ["cli.command", "cli.output_json"] + + // -- Act -- + let context = SentryCommandContext.technologiesList(json: false) + + // -- Assert -- + #expect(context.command == "technologies.list") + #expect(context.typeName == nil) + #expect(context.technology == nil) + #expect(context.outputJSON == false) + #expect(context.attributes.keys.sorted() == expectedAttributeKeys) + #expect(context.metricAttributes.keys.sorted() == ["cli.command"]) + #expect(context.logMetadata.keys.sorted() == expectedAttributeKeys) + } } - - @Test("opts documentation identifiers into types view telemetry") - func includesTypesViewIdentifiers() { - // -- Arrange -- - let expectedKeys = [ - "apple_docs.technology", - "apple_docs.type", - "cli.command", - "cli.output_json", - ] - - // -- Act -- - let context = SentryCommandContext.typesView( - name: "MXHangDiagnostic", - technology: "MetricKit", - json: true - ) - - // -- Assert -- - #expect(context.command == "types.view") - #expect(context.typeName == "MXHangDiagnostic") - #expect(context.technology == "MetricKit") - #expect(context.outputJSON == true) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) - #expect(context.logMetadata.keys.sorted() == expectedKeys) - } - - @Test("opts technology and output mode into types list telemetry") - func includesTypesListContext() { - // -- Arrange -- - let expectedKeys = [ - "apple_docs.technology", - "cli.command", - "cli.output_json", - ] - - // -- Act -- - let context = SentryCommandContext.typesList( - technology: "SwiftData", - json: true - ) - - // -- Assert -- - #expect(context.command == "types.list") - #expect(context.typeName == nil) - #expect(context.technology == "SwiftData") - #expect(context.outputJSON == true) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) - #expect(context.logMetadata.keys.sorted() == expectedKeys) - } - - @Test("excludes the skill name from agent command telemetry") - func excludesAgentSkillName() { - // -- Arrange -- - let expectedKeys = ["cli.command"] - - // -- Act -- - let context = SentryCommandContext.agentSkillsGet - - // -- Assert -- - #expect(context.command == "agent.skills.get") - #expect(context.typeName == nil) - #expect(context.technology == nil) - #expect(context.outputJSON == nil) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys) - #expect(context.logMetadata.keys.sorted() == expectedKeys) - } - - @Test("uses only the command name for cache clean telemetry") - func includesCacheCleanCommand() { - // -- Arrange -- - let expectedKeys = ["cli.command"] - - // -- Act -- - let context = SentryCommandContext.cacheClean - - // -- Assert -- - #expect(context.command == "cache.clean") - #expect(context.typeName == nil) - #expect(context.technology == nil) - #expect(context.outputJSON == nil) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys) - #expect(context.logMetadata.keys.sorted() == expectedKeys) - } - - @Test("opts only output mode into technologies list telemetry") - func includesTechnologiesListOutputMode() { - // -- Arrange -- - let expectedAttributeKeys = ["cli.command", "cli.output_json"] - - // -- Act -- - let context = SentryCommandContext.technologiesList(json: false) - - // -- Assert -- - #expect(context.command == "technologies.list") - #expect(context.typeName == nil) - #expect(context.technology == nil) - #expect(context.outputJSON == false) - #expect(context.attributes.keys.sorted() == expectedAttributeKeys) - #expect(context.metricAttributes.keys.sorted() == ["cli.command"]) - #expect(context.logMetadata.keys.sorted() == expectedAttributeKeys) - } -} +#endif diff --git a/Tests/CLITests/telemetry/SentryConfigurationTests.swift b/Tests/CLITests/telemetry/SentryConfigurationTests.swift index 99bc535..36e7361 100644 --- a/Tests/CLITests/telemetry/SentryConfigurationTests.swift +++ b/Tests/CLITests/telemetry/SentryConfigurationTests.swift @@ -1,76 +1,83 @@ import Foundation -@preconcurrency import SentrySwift import Testing @testable import CLI -@Suite("Sentry configuration") -struct SentryConfigurationTests { - @Test("enables telemetry by default") - func enablesTelemetryByDefault() { - // -- Arrange -- - let environment: [String: String] = [:] +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif - // -- Act -- - let enabled = SentryConfiguration.isEnabled(environment: environment) +#if canImport(SentrySwift) + @preconcurrency import SentrySwift - // -- Assert -- - #expect(enabled) - } - - @Test( - "disables telemetry for a true environmental flag", - arguments: ["true", "TRUE", "True"] - ) - func disablesTelemetry(value: String) { - // -- Arrange -- - let environment = ["TELEMETRY_DISABLED": value] + @Suite("Sentry configuration") + struct SentryConfigurationTests { + @Test("enables telemetry by default") + func enablesTelemetryByDefault() { + // -- Arrange -- + let environment: [String: String] = [:] - // -- Act -- - let enabled = SentryConfiguration.isEnabled(environment: environment) - - // -- Assert -- - #expect(!enabled) - } + // -- Act -- + let enabled = SentryConfiguration.isEnabled(environment: environment) - @Test("marks CLI executable frames as in-app") - func marksCLIExecutableFramesAsInApp() { - // -- Arrange -- - let options = Options() - - // -- Act -- - SentryConfiguration.configure(options) - - // -- Assert -- - #expect(options.inAppIncludes.contains("apple-docs")) - } + // -- Assert -- + #expect(enabled) + } - @Test("treats documentation lookup failures as expected command errors") - func treatsLookupFailureAsExpected() { - // -- Arrange -- - let error = DefaultAppleDocumentationClient.Error.typeNotFound( - name: "Model", - technology: "SwiftData", - suggestion: nil, - technologyURL: "https://developer.apple.com/documentation/swiftdata" + @Test( + "disables telemetry for a true environmental flag", + arguments: ["true", "TRUE", "True"] ) - - // -- Act -- - let expected = SentryConfiguration.isExpected(error: error) - - // -- Assert -- - #expect(expected) - } - - @Test("keeps telemetry enabled for other environmental flag values") - func ignoresOtherFlagValues() { - // -- Arrange -- - let environment = ["TELEMETRY_DISABLED": "false"] - - // -- Act -- - let enabled = SentryConfiguration.isEnabled(environment: environment) - - // -- Assert -- - #expect(enabled) + func disablesTelemetry(value: String) { + // -- Arrange -- + let environment = ["TELEMETRY_DISABLED": value] + + // -- Act -- + let enabled = SentryConfiguration.isEnabled(environment: environment) + + // -- Assert -- + #expect(!enabled) + } + + @Test("marks CLI executable frames as in-app") + func marksCLIExecutableFramesAsInApp() { + // -- Arrange -- + let options = Options() + + // -- Act -- + SentryConfiguration.configure(options) + + // -- Assert -- + #expect(options.inAppIncludes.contains("apple-docs")) + } + + @Test("treats documentation lookup failures as expected command errors") + func treatsLookupFailureAsExpected() { + // -- Arrange -- + let error = DefaultAppleDocumentationClient.Error.typeNotFound( + name: "Model", + technology: "SwiftData", + suggestion: nil, + technologyURL: "https://developer.apple.com/documentation/swiftdata" + ) + + // -- Act -- + let expected = SentryConfiguration.isExpected(error: error) + + // -- Assert -- + #expect(expected) + } + + @Test("keeps telemetry enabled for other environmental flag values") + func ignoresOtherFlagValues() { + // -- Arrange -- + let environment = ["TELEMETRY_DISABLED": "false"] + + // -- Act -- + let enabled = SentryConfiguration.isEnabled(environment: environment) + + // -- Assert -- + #expect(enabled) + } } -} +#endif