diff --git a/Examples/BushelCloud/.gitrepo b/Examples/BushelCloud/.gitrepo index e4c3c566..2141daf1 100644 --- a/Examples/BushelCloud/.gitrepo +++ b/Examples/BushelCloud/.gitrepo @@ -7,6 +7,6 @@ remote = git@github.com:brightdigit/BushelCloud.git branch = mistkit commit = f13f8695a0ca4b041db1e775c239d6cc8b258fb2 - parent = 1ec3d15919adf827cd144f948fc31e822c60ab99 + parent = 95d49423328d8db2778fbf440deea16e651305c8 method = merge cmdver = 0.4.9 diff --git a/Examples/CelestraCloud/.gitrepo b/Examples/CelestraCloud/.gitrepo index 17b8b563..90ee4e15 100644 --- a/Examples/CelestraCloud/.gitrepo +++ b/Examples/CelestraCloud/.gitrepo @@ -7,6 +7,6 @@ remote = git@github.com:brightdigit/CelestraCloud.git branch = mistkit commit = 319bc6208763c31706b9e10c3608d9f7cc5c2ef5 - parent = 10cf4510ab393ad1b4277832fcd8441e32fe0b65 + parent = 95d49423328d8db2778fbf440deea16e651305c8 method = merge cmdver = 0.4.9 diff --git a/Examples/MistDemo/README-INTEGRATION-TESTS.md b/Examples/MistDemo/README-INTEGRATION-TESTS.md new file mode 100644 index 00000000..9c0696de --- /dev/null +++ b/Examples/MistDemo/README-INTEGRATION-TESTS.md @@ -0,0 +1,380 @@ +# MistDemo Integration Tests - Issue #199 + +This document describes the comprehensive integration test suite for the three new CloudKit operations added in issue #199: `lookupZones`, `fetchRecordChanges`, and `uploadAssets`. + +## Overview + +MistDemo has been enhanced with a proper CLI subcommand architecture and comprehensive integration tests that demonstrate all three new operations working together in realistic workflows. + +## Available Commands + +```bash +# Show all available subcommands +mistdemo --help + +# Get help for a specific command +mistdemo test-integration --help +mistdemo upload-asset --help +mistdemo lookup-zones --help +mistdemo fetch-changes --help +``` + +## Quick Start + +### 1. Deploy CloudKit Schema (One-Time Setup) + +Before running integration tests, deploy the schema to CloudKit: + +```bash +# Save your CloudKit management token +xcrun cktool save-token + +# Import the schema to development environment +xcrun cktool import-schema \ + --team-id YOUR_TEAM_ID \ + --container-id iCloud.com.brightdigit.MistDemo \ + --environment development \ + --file schema.ckdb +``` + +### 2. Run Integration Tests + +```bash +# Basic integration test +swift run mistdemo test-integration --api-token YOUR_TOKEN + +# Verbose mode with more records +swift run mistdemo test-integration \ + --api-token YOUR_TOKEN \ + --record-count 20 \ + --verbose + +# Skip cleanup to inspect records in CloudKit Console +swift run mistdemo test-integration \ + --api-token YOUR_TOKEN \ + --skip-cleanup +``` + +## Individual Operation Commands + +### Upload Asset + +Upload a binary file to CloudKit: + +```bash +# Upload a file +swift run mistdemo upload-asset photo.jpg --api-token YOUR_TOKEN + +# Upload and create a record +swift run mistdemo upload-asset document.pdf \ + --create-record Document \ + --api-token YOUR_TOKEN +``` + +**Features:** +- Validates file existence +- Displays upload progress +- Returns asset tokens +- Optionally creates a record with the asset + +### Lookup Zones + +Fetch detailed information about specific zones: + +```bash +# Lookup default zone +swift run mistdemo lookup-zones "_defaultZone" --api-token YOUR_TOKEN + +# Lookup multiple zones +swift run mistdemo lookup-zones "Photos,Documents,Articles" --api-token YOUR_TOKEN +``` + +**Features:** +- Supports comma-separated zone names +- Displays zone capabilities +- Shows owner information + +### Fetch Changes + +Fetch record changes with incremental sync: + +```bash +# Initial fetch +swift run mistdemo fetch-changes --api-token YOUR_TOKEN + +# Incremental fetch with sync token +swift run mistdemo fetch-changes \ + --sync-token "abc123..." \ + --api-token YOUR_TOKEN + +# Fetch all with automatic pagination +swift run mistdemo fetch-changes --fetch-all --api-token YOUR_TOKEN + +# Custom zone and limit +swift run mistdemo fetch-changes \ + --zone MyZone \ + --limit 50 \ + --api-token YOUR_TOKEN +``` + +**Features:** +- Supports sync tokens for incremental sync +- Automatic pagination with `--fetch-all` +- Displays sync tokens for next fetch +- Shows `moreComing` flag + +## Integration Test Suite + +The `test-integration` command runs a comprehensive 8-phase workflow: + +### Phase 1: Zone Verification +- Uses `lookupZones(zoneIDs: [.defaultZone])` +- Verifies zone exists before testing +- Displays zone capabilities + +### Phase 2: Asset Upload +- Generates test PNG image programmatically +- Uploads using `uploadAssets(data:)` +- Configurable size via `--asset-size` (default: 100 KB) + +### Phase 3: Create Records with Assets +- Creates N test records (default: 10) +- Each record includes: title, index, image asset, timestamp +- Uses record type: `MistKitIntegrationTest` + +### Phase 4: Initial Sync +- Fetches all records with `fetchRecordChanges()` +- Saves sync token +- Verifies test records are included + +### Phase 5: Modify Records +- Updates first 3 records +- Adds "modified" boolean field +- Changes title field + +### Phase 6: Incremental Sync +- Uses saved sync token +- Fetches only modified records +- Demonstrates incremental sync efficiency + +### Phase 7: Final Zone Verification +- Re-verifies zone state +- Ensures operations didn't corrupt zone + +### Phase 8: Cleanup +- Deletes all test records +- Skip with `--skip-cleanup` flag +- Partial cleanup on errors + +## Command Options + +### Common Options + +All subcommands support: + +```bash +--api-token # CloudKit API token (or set CLOUDKIT_API_TOKEN env var) +--container-identifier # Container ID (default: iCloud.com.brightdigit.MistDemo) +--environment # development or production (default: development) +``` + +### Test Integration Options + +```bash +--record-count # Number of test records (default: 10) +--asset-size # Asset size in KB (default: 100) +--skip-cleanup # Leave test records in CloudKit +--verbose # Show detailed progress +``` + +## CloudKit Schema + +The integration tests use a custom record type defined in `schema.ckdb`: + +``` +RECORD TYPE MistKitIntegrationTest ( + "title" STRING QUERYABLE SORTABLE SEARCHABLE, + "index" INT64 QUERYABLE SORTABLE, + "image" ASSET, + "createdAt" TIMESTAMP QUERYABLE SORTABLE, + "modified" INT64 QUERYABLE, + + GRANT READ, CREATE, WRITE TO "_creator", + GRANT READ, CREATE, WRITE TO "_icloud", + GRANT READ TO "_world" +); +``` + +**Field Descriptions:** +- `title` - Test record title +- `index` - Sequential number for ordering +- `image` - Uploaded test asset +- `createdAt` - Record creation timestamp +- `modified` - Boolean flag (0/1) set during updates + +## Example Output + +``` +================================================================================ +๐Ÿงช Integration Test Suite: CloudKit Operations +================================================================================ +Container: iCloud.com.brightdigit.MistDemo +Environment: development +Database: public +Record Count: 10 +Asset Size: 100 KB +================================================================================ + +๐Ÿ“‹ Phase 1: Verify zone exists +โœ… Found zone: _defaultZone + +๐Ÿ“ค Phase 2: Upload test assets +โœ… Uploaded asset: 102400 bytes + +๐Ÿ“ Phase 3: Create records with assets +โœ… Created 10 records + +๐Ÿ”„ Phase 4: Initial sync (fetch all changes) +โœ… Fetched 45 records + Found 10 of our test records + +โœ๏ธ Phase 5: Modify some records +โœ… Updated 3 records + +๐Ÿ”„ Phase 6: Incremental sync (fetch only changes) +โœ… Fetched 3 changed records + Found 3 of our modified records + +๐Ÿ” Phase 7: Lookup zone details +โœ… Zone verification complete + +๐Ÿงน Phase 8: Cleanup test records +โœ… Deleted 10 test records + +================================================================================ +โœ… Integration Test Complete! +================================================================================ + +Phases Completed: + โœ… Zone verification with lookupZones + โœ… Asset upload with uploadAssets + โœ… Record creation with assets + โœ… Initial sync with fetchRecordChanges + โœ… Record modifications + โœ… Incremental sync with sync token + โœ… Final zone verification + โœ… Cleanup completed +``` + +## Troubleshooting + +### Schema Not Found + +If you see errors about `MistKitIntegrationTest` not existing: + +1. Verify schema was deployed: Check CloudKit Console โ†’ Schema +2. Re-import schema using `cktool import-schema` +3. Ensure you're using the correct environment (`--environment development`) + +### Authentication Failed + +If you see authentication errors: + +1. Verify your API token: https://icloud.developer.apple.com/dashboard/ +2. Check token has permissions for the container +3. Ensure token hasn't expired +4. Try setting `CLOUDKIT_API_TOKEN` environment variable + +### No Records Found + +If integration tests report no records found: + +1. Records may not be immediately available after creation +2. Try running the test again +3. Use `--skip-cleanup` and check CloudKit Console +4. Verify you're using the correct database (public vs private) + +## Architecture + +### File Structure + +``` +Examples/MistDemo/ +โ”œโ”€โ”€ schema.ckdb # CloudKit schema +โ”œโ”€โ”€ Sources/MistDemo/ +โ”‚ โ”œโ”€โ”€ MistDemo.swift # Command group + CloudKitCommand protocol +โ”‚ โ”œโ”€โ”€ Commands/ +โ”‚ โ”‚ โ”œโ”€โ”€ Auth.swift # Legacy auth server +โ”‚ โ”‚ โ”œโ”€โ”€ UploadAsset.swift # Upload asset command +โ”‚ โ”‚ โ”œโ”€โ”€ LookupZones.swift # Lookup zones command +โ”‚ โ”‚ โ”œโ”€โ”€ FetchChanges.swift # Fetch changes command +โ”‚ โ”‚ โ””โ”€โ”€ TestIntegration.swift # Integration test command +โ”‚ โ”œโ”€โ”€ Integration/ +โ”‚ โ”‚ โ”œโ”€โ”€ IntegrationTestRunner.swift # 8-phase test orchestration +โ”‚ โ”‚ โ”œโ”€โ”€ IntegrationTestData.swift # Test data generation +โ”‚ โ”‚ โ””โ”€โ”€ IntegrationTestError.swift # Error types +โ”‚ โ”œโ”€โ”€ Models/ +โ”‚ โ”œโ”€โ”€ Utilities/ +โ”‚ โ””โ”€โ”€ Resources/ +``` + +### CloudKitCommand Protocol + +All subcommands conform to the `CloudKitCommand` protocol: + +```swift +protocol CloudKitCommand { + var containerIdentifier: String { get } + var apiToken: String { get } + var environment: String { get } +} + +extension CloudKitCommand { + func resolvedApiToken() -> String + func cloudKitEnvironment() -> MistKit.Environment +} +``` + +## Testing with Live CloudKit + +To test against your own CloudKit container: + +1. Create a container at https://icloud.developer.apple.com/ +2. Generate an API token +3. Deploy the schema to your container +4. Update the default container ID or use `--container-identifier` +5. Run tests: + +```bash +swift run mistdemo test-integration \ + --container-identifier iCloud.com.yourcompany.YourApp \ + --api-token YOUR_TOKEN \ + --environment development +``` + +## Future Enhancements + +Documented in the plan: + +1. **Custom Zone Support** - Add `modifyZones` wrapper to test multi-zone scenarios +2. **Pagination Testing** - Create 50+ records to trigger pagination +3. **Error Scenario Testing** - Test invalid zones, corrupted tokens, oversized assets +4. **Concurrent Operations** - Test parallel uploads and modifications +5. **Performance Metrics** - Track timing for each phase +6. **JSON Output** - Machine-readable results for CI/CD integration + +## Contributing + +When adding new CloudKit operations: + +1. Create a subcommand in `Commands/` +2. Implement the `CloudKitCommand` protocol +3. Add to the `MistDemo.configuration.subcommands` array +4. Update integration tests if needed +5. Document usage in this README + +## References + +- Issue #199: CloudKit API Coverage +- Commit: 1d0b348 - Add lookupZones, fetchRecordChanges, and uploadAssets operations +- Plan: `/Users/leo/.claude/plans/eager-roaming-rainbow.md` diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/FetchChanges.swift b/Examples/MistDemo/Sources/MistDemo/Commands/FetchChanges.swift new file mode 100644 index 00000000..b85a91ad --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemo/Commands/FetchChanges.swift @@ -0,0 +1,154 @@ +// +// FetchChanges.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright ยฉ 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import MistKit + +/// Fetch record changes with incremental sync +struct FetchChanges: AsyncParsableCommand, CloudKitCommand { + static let configuration = CommandConfiguration( + commandName: "fetch-changes", + abstract: "Fetch record changes with incremental sync" + ) + + @Option(name: .shortAndLong, help: "CloudKit container identifier") + var containerIdentifier: String = "iCloud.com.brightdigit.MistDemo" + + @Option(name: .shortAndLong, help: "CloudKit API token") + var apiToken: String = "" + + @Option(name: .long, help: "Environment (development or production)") + var environment: String = "development" + + @Option(name: .long, help: "Sync token from previous fetch") + var syncToken: String? + + @Option(name: .long, help: "Zone name (default: _defaultZone)") + var zone: String = "_defaultZone" + + @Flag(name: .long, help: "Fetch all changes automatically with pagination") + var fetchAll: Bool = false + + @Option(name: .long, help: "Maximum results per page (1-200)") + var limit: Int? + + func run() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("๐Ÿ”„ Fetch Record Changes") + print(String(repeating: "=", count: 60)) + + let resolvedToken = resolvedApiToken() + + guard !resolvedToken.isEmpty else { + print("\nโŒ Error: CloudKit API token is required") + print(" Provide it via --api-token or set CLOUDKIT_API_TOKEN environment variable") + print(" Get your API token from: https://icloud.developer.apple.com/dashboard/") + return + } + + let tokenManager = APITokenManager(apiToken: resolvedToken) + let service = try CloudKitService( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: cloudKitEnvironment(), + database: .public + ) + + do { + if fetchAll { + print("\n๐Ÿ“ฆ Fetching all changes (automatic pagination)...") + if let token = syncToken { + print(" Using sync token: \(token.prefix(20))...") + } else { + print(" Performing initial fetch (no sync token)") + } + + let (records, newToken) = try await service.fetchAllRecordChanges( + syncToken: syncToken + ) + print("\nโœ… Fetched \(records.count) record(s)") + displayRecords(records, limit: 5) + if let token = newToken { + print("\n๐Ÿ’พ New sync token: \(token.prefix(20))...") + print(" Save this token to fetch only new changes next time:") + print(" mistdemo fetch-changes --sync-token '\(token)'") + } + } else { + print("\n๐Ÿ“„ Fetching single page...") + if let token = syncToken { + print(" Using sync token: \(token.prefix(20))...") + } else { + print(" Performing initial fetch (no sync token)") + } + + let result = try await service.fetchRecordChanges( + syncToken: syncToken, + resultsLimit: limit ?? 10 + ) + print("\nโœ… Fetched \(result.records.count) record(s)") + displayRecords(result.records, limit: 5) + + if result.moreComing { + print("\nโš ๏ธ More changes available! Use --sync-token with:") + if let token = result.syncToken { + print(" mistdemo fetch-changes --sync-token '\(token)'") + } + } + + if let token = result.syncToken { + print("\n๐Ÿ’พ Sync token: \(token.prefix(20))...") + } + } + } catch let error as CloudKitError { + print("\nโŒ CloudKit Error: \(error)") + throw error + } catch { + print("\nโŒ Error: \(error)") + throw error + } + + print("\n" + String(repeating: "=", count: 60)) + print("โœ… Fetch completed!") + print(String(repeating: "=", count: 60)) + } + + private func displayRecords(_ records: [RecordInfo], limit: Int) { + let displayed = records.prefix(limit) + for record in displayed { + print(" ๐Ÿ“ \(record.recordType) - \(record.recordName)") + if !record.fields.isEmpty { + print(" Fields: \(record.fields.keys.joined(separator: ", "))") + } + } + if records.count > limit { + print(" ... and \(records.count - limit) more") + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/LookupZones.swift b/Examples/MistDemo/Sources/MistDemo/Commands/LookupZones.swift new file mode 100644 index 00000000..60643fa9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemo/Commands/LookupZones.swift @@ -0,0 +1,107 @@ +// +// LookupZones.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright ยฉ 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import MistKit + +/// Look up specific CloudKit zones by name +struct LookupZones: AsyncParsableCommand, CloudKitCommand { + static let configuration = CommandConfiguration( + commandName: "lookup-zones", + abstract: "Look up specific CloudKit zones by name" + ) + + @Option(name: .shortAndLong, help: "CloudKit container identifier") + var containerIdentifier: String = "iCloud.com.brightdigit.MistDemo" + + @Option(name: .shortAndLong, help: "CloudKit API token") + var apiToken: String = "" + + @Option(name: .long, help: "Environment (development or production)") + var environment: String = "development" + + @Argument(help: "Zone names to lookup (comma-separated)") + var zoneNames: String = "_defaultZone" + + func run() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("๐Ÿ” Lookup CloudKit Zones") + print(String(repeating: "=", count: 60)) + + let resolvedToken = resolvedApiToken() + + guard !resolvedToken.isEmpty else { + print("\nโŒ Error: CloudKit API token is required") + print(" Provide it via --api-token or set CLOUDKIT_API_TOKEN environment variable") + print(" Get your API token from: https://icloud.developer.apple.com/dashboard/") + return + } + + let tokenManager = APITokenManager(apiToken: resolvedToken) + let service = try CloudKitService( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: cloudKitEnvironment(), + database: .public + ) + + let zoneNamesList = zoneNames.split(separator: ",").map(String.init) + let zoneIDs = zoneNamesList.map { ZoneID(zoneName: $0, ownerName: nil) } + + print("\n๐Ÿ“‹ Looking up \(zoneIDs.count) zone(s):") + for zoneName in zoneNamesList { + print(" - \(zoneName)") + } + + do { + let zones = try await service.lookupZones(zoneIDs: zoneIDs) + print("\nโœ… Found \(zones.count) zone(s):") + for zone in zones { + print(" - \(zone.zoneName)") + if let owner = zone.ownerRecordName { + print(" Owner: \(owner)") + } + if !zone.capabilities.isEmpty { + print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") + } + } + } catch let error as CloudKitError { + print("\nโŒ CloudKit Error: \(error)") + throw error + } catch { + print("\nโŒ Error: \(error)") + throw error + } + + print("\n" + String(repeating: "=", count: 60)) + print("โœ… Lookup completed!") + print(String(repeating: "=", count: 60)) + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/TestIntegration.swift b/Examples/MistDemo/Sources/MistDemo/Commands/TestIntegration.swift new file mode 100644 index 00000000..323c1672 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemo/Commands/TestIntegration.swift @@ -0,0 +1,107 @@ +// +// TestIntegration.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright ยฉ 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import MistKit + +/// Run comprehensive integration tests for all CloudKit operations +struct TestIntegration: AsyncParsableCommand, CloudKitCommand { + static let configuration = CommandConfiguration( + commandName: "test-integration", + abstract: "Run comprehensive integration tests for all CloudKit operations" + ) + + @Option(name: .shortAndLong, help: "CloudKit container identifier") + var containerIdentifier: String = "iCloud.com.brightdigit.MistDemo" + + @Option(name: .shortAndLong, help: "CloudKit API token") + var apiToken: String = "" + + @Option(name: .long, help: "Environment (development or production)") + var environment: String = "development" + + @Option(name: .long, help: "Database to use (public or private)") + var database: String = "private" + + @Option(name: .long, help: "Web auth token for private database access") + var webAuthToken: String = "" + + @Option(name: .long, help: "Number of test records to create (default: 10)") + var recordCount: Int = 10 + + @Option(name: .long, help: "Asset size for integration test in KB (default: 100)") + var assetSize: Int = 100 + + @Flag(name: .long, help: "Skip cleanup after integration test") + var skipCleanup: Bool = false + + @Flag(name: .long, help: "Run integration test in verbose mode") + var verbose: Bool = false + + func run() async throws { + let resolvedToken = resolvedApiToken() + + guard !resolvedToken.isEmpty else { + print("โŒ Error: CloudKit API token is required") + print(" Provide it via --api-token or set CLOUDKIT_API_TOKEN environment variable") + print(" Get your API token from: https://icloud.developer.apple.com/dashboard/") + return + } + + // Resolve web auth token from option or environment variable + let resolvedWebAuthToken = webAuthToken.isEmpty ? + ProcessInfo.processInfo.environment["CLOUDKIT_WEBAUTH_TOKEN"] ?? "" : + webAuthToken + + // Check if web auth token is required for private database + if database == "private" && resolvedWebAuthToken.isEmpty { + print("โŒ Error: Web auth token is required for private database access") + print(" Provide it via --web-auth-token or set CLOUDKIT_WEBAUTH_TOKEN environment variable") + print(" Use the 'auth' command to authenticate and get a web auth token") + return + } + + let cloudKitDatabase: MistKit.Database = database == "public" ? .public : .private + + let runner = IntegrationTestRunner( + containerIdentifier: containerIdentifier, + apiToken: resolvedToken, + webAuthToken: resolvedWebAuthToken, + environment: cloudKitEnvironment(), + database: cloudKitDatabase, + recordCount: recordCount, + assetSizeKB: assetSize, + skipCleanup: skipCleanup, + verbose: verbose + ) + + try await runner.runBasicWorkflow() + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Commands/UploadAsset.swift b/Examples/MistDemo/Sources/MistDemo/Commands/UploadAsset.swift new file mode 100644 index 00000000..4f72c716 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemo/Commands/UploadAsset.swift @@ -0,0 +1,161 @@ +// +// UploadAsset.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright ยฉ 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import MistKit + +/// Upload binary assets to CloudKit +struct UploadAsset: AsyncParsableCommand, CloudKitCommand { + static let configuration = CommandConfiguration( + commandName: "upload-asset", + abstract: "Upload binary assets to CloudKit" + ) + + @Option(name: .shortAndLong, help: "CloudKit container identifier") + var containerIdentifier: String = "iCloud.com.brightdigit.MistDemo" + + @Option(name: .shortAndLong, help: "CloudKit API token") + var apiToken: String = "" + + @Option(name: .long, help: "Environment (development or production)") + var environment: String = "development" + + @Argument(help: "Path to file to upload") + var file: String + + @Option(name: .long, help: "Create record of this type with uploaded asset") + var createRecord: String? + + func run() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("๐Ÿ“ค Upload Asset to CloudKit") + print(String(repeating: "=", count: 60)) + + let resolvedToken = resolvedApiToken() + + guard !resolvedToken.isEmpty else { + print("\nโŒ Error: CloudKit API token is required") + print(" Provide it via --api-token or set CLOUDKIT_API_TOKEN environment variable") + print(" Get your API token from: https://icloud.developer.apple.com/dashboard/") + return + } + + let fileURL = URL(fileURLWithPath: file) + + guard FileManager.default.fileExists(atPath: file) else { + print("\nโŒ Error: File not found at path: \(file)") + return + } + + let tokenManager = APITokenManager(apiToken: resolvedToken) + let service = try CloudKitService( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: cloudKitEnvironment(), + database: .public + ) + + do { + let data = try Data(contentsOf: fileURL) + let sizeInMB = Double(data.count) / 1024 / 1024 + print("\n๐Ÿ“ File: \(fileURL.lastPathComponent) (\(String(format: "%.2f", sizeInMB)) MB)") + + print("โฌ†๏ธ Uploading...") + let result = try await service.uploadAssets(data: data) + + print("\nโœ… Upload successful!") + print("๐ŸŽซ Received \(result.tokens.count) token(s):") + for (index, token) in result.tokens.enumerated() { + print(" Token \(index + 1):") + if let url = token.url { + print(" URL: \(url.prefix(50))...") + } + if let recordName = token.recordName { + print(" Record: \(recordName)") + } + if let fieldName = token.fieldName { + print(" Field: \(fieldName)") + } + } + + // Optional: Create record with asset + if let recordType = createRecord, let token = result.tokens.first { + print("\n๐Ÿ“ Creating \(recordType) record with asset...") + try await createRecordWithAsset( + service: service, + recordType: recordType, + filename: fileURL.lastPathComponent, + token: token, + fileSize: data.count + ) + } + + } catch let error as CloudKitError { + print("\nโŒ CloudKit Error: \(error)") + throw error + } catch { + print("\nโŒ Error: \(error)") + throw error + } + + print("\n" + String(repeating: "=", count: 60)) + print("โœ… Upload completed!") + print(String(repeating: "=", count: 60)) + } + + private func createRecordWithAsset( + service: CloudKitService, + recordType: String, + filename: String, + token: AssetUploadToken, + fileSize: Int + ) async throws { + let asset = FieldValue.Asset( + fileChecksum: nil, + size: Int64(fileSize), + referenceChecksum: nil, + wrappingKey: nil, + receipt: nil, + downloadURL: token.url + ) + + let record = try await service.createRecord( + recordType: recordType, + fields: [ + "filename": .string(filename), + "file": .asset(asset) + ] + ) + + print(" โœ… Created record: \(record.recordName)") + print(" ๐Ÿ“ Type: \(record.recordType)") + print(" ๐Ÿ†” Record ID: \(record.recordName)") + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestData.swift b/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestData.swift new file mode 100644 index 00000000..bb084aa1 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestData.swift @@ -0,0 +1,112 @@ +// +// IntegrationTestData.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright ยฉ 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Test data generation utilities for integration tests +struct IntegrationTestData { + /// CloudKit record type for integration tests + static let recordType = "MistKitIntegrationTest" + + /// Generate minimal valid PNG image data + /// - Parameter sizeKB: Desired size in kilobytes (default: 10) + /// - Returns: PNG image data + static func generateTestImage(sizeKB: Int = 10) -> Data { + // Minimal valid 1x1 pixel PNG + // PNG signature + var data = Data([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + ]) + + // IHDR chunk (image header) for 1x1 pixel RGBA image + let ihdrData: [UInt8] = [ + 0x00, 0x00, 0x00, 0x0D, // Chunk length: 13 bytes + 0x49, 0x48, 0x44, 0x52, // Chunk type: "IHDR" + 0x00, 0x00, 0x00, 0x01, // Width: 1 + 0x00, 0x00, 0x00, 0x01, // Height: 1 + 0x08, // Bit depth: 8 + 0x06, // Color type: RGBA + 0x00, // Compression: deflate + 0x00, // Filter: adaptive + 0x00, // Interlace: none + 0x1F, 0x15, 0xC4, 0x89 // CRC32 checksum + ] + data.append(contentsOf: ihdrData) + + // IDAT chunk (image data) - minimal compressed pixel data + let idatData: [UInt8] = [ + 0x00, 0x00, 0x00, 0x0C, // Chunk length: 12 bytes + 0x49, 0x44, 0x41, 0x54, // Chunk type: "IDAT" + 0x08, 0x1D, 0x01, 0x02, 0x00, 0xFD, 0xFF, // Compressed data + 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, + 0xE2, 0x21, 0xBC, 0x33 // CRC32 checksum + ] + data.append(contentsOf: idatData) + + // IEND chunk (image trailer) + let iendData: [UInt8] = [ + 0x00, 0x00, 0x00, 0x00, // Chunk length: 0 + 0x49, 0x45, 0x4E, 0x44, // Chunk type: "IEND" + 0xAE, 0x42, 0x60, 0x82 // CRC32 checksum + ] + data.append(contentsOf: iendData) + + // Pad to requested size with additional IDAT chunks if needed + let targetSize = sizeKB * 1024 + while data.count < targetSize { + // Add padding IDAT chunks + let remainingBytes = targetSize - data.count + let chunkSize = min(8192, remainingBytes - 12) // Leave room for chunk overhead + + if chunkSize <= 0 { + break + } + + // Chunk length (4 bytes) + var lengthBytes: [UInt8] = [ + UInt8((chunkSize >> 24) & 0xFF), + UInt8((chunkSize >> 16) & 0xFF), + UInt8((chunkSize >> 8) & 0xFF), + UInt8(chunkSize & 0xFF) + ] + data.append(contentsOf: lengthBytes) + + // Chunk type: "IDAT" + data.append(contentsOf: [0x49, 0x44, 0x41, 0x54]) + + // Padding data + data.append(contentsOf: Array(repeating: UInt8(0x00), count: chunkSize)) + + // Simple CRC32 (not accurate, but sufficient for test data) + data.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) + } + + return data + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestError.swift b/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestError.swift new file mode 100644 index 00000000..ee06c51e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestError.swift @@ -0,0 +1,60 @@ +// +// IntegrationTestError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright ยฉ 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Errors that can occur during integration testing +enum IntegrationTestError: LocalizedError, Sendable { + case zoneNotFound(String) + case uploadFailed(String) + case recordCreationFailed(String) + case syncTokenMissing + case verificationFailed(String) + case cleanupFailed(String) + case noRecordsCreated + + var errorDescription: String? { + switch self { + case .zoneNotFound(let zone): + return "Zone not found: \(zone)" + case .uploadFailed(let reason): + return "Asset upload failed: \(reason)" + case .recordCreationFailed(let reason): + return "Record creation failed: \(reason)" + case .syncTokenMissing: + return "Sync token not available from initial fetch" + case .verificationFailed(let reason): + return "Verification failed: \(reason)" + case .cleanupFailed(let reason): + return "Cleanup failed: \(reason)" + case .noRecordsCreated: + return "No records were successfully created" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestRunner.swift b/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestRunner.swift new file mode 100644 index 00000000..52658aaa --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestRunner.swift @@ -0,0 +1,456 @@ +// +// IntegrationTestRunner.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright ยฉ 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Orchestrates comprehensive integration tests for CloudKit operations +struct IntegrationTestRunner { + let containerIdentifier: String + let apiToken: String + let webAuthToken: String + let environment: MistKit.Environment + let database: MistKit.Database + let recordCount: Int + let assetSizeKB: Int + let skipCleanup: Bool + let verbose: Bool + + /// Run the basic integration workflow testing all operations + func runBasicWorkflow() async throws { + print("\n" + String(repeating: "=", count: 80)) + print("๐Ÿงช Integration Test Suite: CloudKit Operations") + print(String(repeating: "=", count: 80)) + print("Container: \(containerIdentifier)") + print("Environment: \(environment == .production ? "production" : "development")") + print("Database: \(database == .public ? "public" : "private")") + print("Record Count: \(recordCount)") + print("Asset Size: \(assetSizeKB) KB") + print("Web Auth: \(webAuthToken.isEmpty ? "No" : "Yes")") + print(String(repeating: "=", count: 80)) + + // Initialize service + let tokenManager: any TokenManager + if database == .private && !webAuthToken.isEmpty { + // Use AdaptiveTokenManager for private database with web auth + let storage = InMemoryTokenStorage() + // Pre-populate storage with web auth credentials + let credentials = TokenCredentials( + method: .webAuthToken(apiToken: apiToken, webToken: webAuthToken) + ) + try await storage.store(credentials) + + tokenManager = AdaptiveTokenManager( + apiToken: apiToken, + storage: storage + ) + } else { + // Use APITokenManager for public database + tokenManager = APITokenManager(apiToken: apiToken) + } + + let service = try CloudKitService( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: environment, + database: database + ) + + var createdRecordNames: [String] = [] + var syncToken: String? + + do { + // PHASE 1: Zone Verification + try await phase1VerifyZone(service: service) + + // PHASE 2: Asset Upload + let assetToken = try await phase2UploadAsset(service: service) + + // PHASE 3: Create Records with Assets + createdRecordNames = try await phase3CreateRecords( + service: service, + assetToken: assetToken + ) + + // PHASE 4: Initial Sync + syncToken = try await phase4InitialSync( + service: service, + createdRecordNames: createdRecordNames + ) + + // PHASE 5: Modify Records + try await phase5ModifyRecords( + service: service, + createdRecordNames: createdRecordNames + ) + + // PHASE 6: Incremental Sync + try await phase6IncrementalSync( + service: service, + syncToken: syncToken, + createdRecordNames: createdRecordNames + ) + + // PHASE 7: Final Zone Verification + try await phase7FinalVerification(service: service) + + // PHASE 8: Cleanup + if !skipCleanup { + try await phase8Cleanup( + service: service, + createdRecordNames: createdRecordNames + ) + } else { + printSkippedCleanup(recordNames: createdRecordNames) + } + + // Print summary + printSuccessSummary() + + } catch let error as CloudKitError { + print("\nโŒ CloudKit Error: \(error)") + if !createdRecordNames.isEmpty && !skipCleanup { + print("\nโš ๏ธ Attempting cleanup of \(createdRecordNames.count) test records...") + try? await phase8Cleanup( + service: service, + createdRecordNames: createdRecordNames + ) + } + throw error + } catch { + print("\nโŒ Error: \(error)") + if !createdRecordNames.isEmpty && !skipCleanup { + print("\nโš ๏ธ Attempting cleanup of \(createdRecordNames.count) test records...") + try? await phase8Cleanup( + service: service, + createdRecordNames: createdRecordNames + ) + } + throw error + } + } + + // MARK: - Phase 1: Zone Verification + + private func phase1VerifyZone(service: CloudKitService) async throws { + print("\n๐Ÿ“‹ Phase 1: Verify zone exists") + + let zones = try await service.lookupZones(zoneIDs: [.defaultZone]) + + guard !zones.isEmpty else { + throw IntegrationTestError.zoneNotFound("_defaultZone") + } + + let zone = zones[0] + print("โœ… Found zone: \(zone.zoneName)") + + if verbose { + if let owner = zone.ownerRecordName { + print(" Owner: \(owner)") + } + if !zone.capabilities.isEmpty { + print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") + } + } + } + + // MARK: - Phase 2: Asset Upload + + private func phase2UploadAsset(service: CloudKitService) async throws -> AssetUploadToken { + print("\n๐Ÿ“ค Phase 2: Upload test assets") + + let testData = IntegrationTestData.generateTestImage(sizeKB: assetSizeKB) + let sizeInMB = Double(testData.count) / 1024 / 1024 + + if verbose { + print(" Uploading \(testData.count) bytes (\(String(format: "%.2f", sizeInMB)) MB)...") + } + + let uploadResult = try await service.uploadAssets(data: testData) + + guard let token = uploadResult.tokens.first else { + throw IntegrationTestError.uploadFailed("No tokens returned") + } + + print("โœ… Uploaded asset: \(testData.count) bytes") + + if verbose, let url = token.url { + print(" Token URL: \(url.prefix(60))...") + } + + return token + } + + // MARK: - Phase 3: Create Records + + private func phase3CreateRecords( + service: CloudKitService, + assetToken: AssetUploadToken + ) async throws -> [String] { + print("\n๐Ÿ“ Phase 3: Create records with assets") + + if verbose { + print(" Creating \(recordCount) records...") + } + + var createdRecordNames: [String] = [] + + for i in 1...recordCount { + let asset = FieldValue.Asset( + fileChecksum: nil, + size: Int64(assetSizeKB * 1024), + referenceChecksum: nil, + wrappingKey: nil, + receipt: nil, + downloadURL: assetToken.url + ) + + let record = try await service.createRecord( + recordType: IntegrationTestData.recordType, + fields: [ + "title": .string("Test Record \(i)"), + "index": .int64(i), + "image": .asset(asset), + "createdAt": .date(Date()) + ] + ) + + createdRecordNames.append(record.recordName) + + if verbose { + print(" โœ… Created: \(record.recordName)") + } + } + + guard !createdRecordNames.isEmpty else { + throw IntegrationTestError.noRecordsCreated + } + + print("โœ… Created \(createdRecordNames.count) records") + + return createdRecordNames + } + + // MARK: - Phase 4: Initial Sync + + private func phase4InitialSync( + service: CloudKitService, + createdRecordNames: [String] + ) async throws -> String? { + print("\n๐Ÿ”„ Phase 4: Initial sync (fetch all changes)") + + let initialResult = try await service.fetchRecordChanges() + + print("โœ… Fetched \(initialResult.records.count) records") + + if verbose { + if let token = initialResult.syncToken { + print(" Sync token: \(token.prefix(30))...") + } + print(" More coming: \(initialResult.moreComing)") + } + + // Find our test records + let ourRecords = initialResult.records.filter { + createdRecordNames.contains($0.recordName) + } + + print(" Found \(ourRecords.count) of our test records") + + if ourRecords.count != createdRecordNames.count && verbose { + print(" โš ๏ธ Expected \(createdRecordNames.count), found \(ourRecords.count)") + print(" (Records may not be immediately available)") + } + + return initialResult.syncToken + } + + // MARK: - Phase 5: Modify Records + + private func phase5ModifyRecords( + service: CloudKitService, + createdRecordNames: [String] + ) async throws { + print("\nโœ๏ธ Phase 5: Modify some records") + + let updateCount = min(3, createdRecordNames.count) + + for i in 0.. 0 { + print(" Modified records:") + for record in changedRecords { + print(" - \(record.recordName)") + } + } + } + + // MARK: - Phase 7: Final Zone Verification + + private func phase7FinalVerification(service: CloudKitService) async throws { + print("\n๐Ÿ” Phase 7: Lookup zone details") + + let finalZones = try await service.lookupZones(zoneIDs: [.defaultZone]) + + guard !finalZones.isEmpty else { + throw IntegrationTestError.verificationFailed("Zone not found after operations") + } + + print("โœ… Zone verification complete") + } + + // MARK: - Phase 8: Cleanup + + private func phase8Cleanup( + service: CloudKitService, + createdRecordNames: [String] + ) async throws { + print("\n๐Ÿงน Phase 8: Cleanup test records") + + var deletedCount = 0 + + for recordName in createdRecordNames { + do { + try await service.deleteRecord( + recordType: IntegrationTestData.recordType, + recordName: recordName + ) + deletedCount += 1 + + if verbose { + print(" โœ… Deleted: \(recordName)") + } + } catch { + if verbose { + print(" โš ๏ธ Failed to delete \(recordName): \(error)") + } + } + } + + print("โœ… Deleted \(deletedCount) test records") + + if deletedCount < createdRecordNames.count { + let failedCount = createdRecordNames.count - deletedCount + print(" โš ๏ธ Failed to delete \(failedCount) records") + } + } + + // MARK: - Helper Methods + + private func printSkippedCleanup(recordNames: [String]) { + print("\nโš ๏ธ Skipping cleanup (--skip-cleanup flag set)") + print(" Test records left in CloudKit:") + for name in recordNames { + print(" - \(name)") + } + print("\nTo manually cleanup these records:") + print(" 1. Visit https://icloud.developer.apple.com/dashboard/") + print(" 2. Select your container: \(containerIdentifier)") + print(" 3. Navigate to Public Database โ†’ Records") + print(" 4. Search for record type: \(IntegrationTestData.recordType)") + } + + private func printSuccessSummary() { + print("\n" + String(repeating: "=", count: 80)) + print("โœ… Integration Test Complete!") + print(String(repeating: "=", count: 80)) + print("\nPhases Completed:") + print(" โœ… Zone verification with lookupZones") + print(" โœ… Asset upload with uploadAssets") + print(" โœ… Record creation with assets") + print(" โœ… Initial sync with fetchRecordChanges") + print(" โœ… Record modifications") + print(" โœ… Incremental sync with sync token") + print(" โœ… Final zone verification") + + if !skipCleanup { + print(" โœ… Cleanup completed") + } else { + print(" โญ๏ธ Cleanup skipped") + } + + print("\n๐Ÿ’ก Next steps:") + print(" โ€ข Run with --verbose for detailed output") + print(" โ€ข Use --skip-cleanup to inspect records in CloudKit Console") + print(" โ€ข Adjust --record-count for stress testing") + print(" โ€ข Try --asset-size for larger file uploads") + } +} diff --git a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift index f277f738..43d347cb 100644 --- a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift +++ b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift @@ -38,6 +38,31 @@ import ConfigKeyKit import AppKit #endif +// MARK: - CloudKit Command Protocol + +/// Protocol for commands that interact with CloudKit +protocol CloudKitCommand { + var containerIdentifier: String { get } + var apiToken: String { get } + var environment: String { get } +} + +extension CloudKitCommand { + /// Resolve API token from option or environment variable + func resolvedApiToken() -> String { + apiToken.isEmpty ? + EnvironmentConfig.getOptional(EnvironmentConfig.Keys.cloudKitAPIToken) ?? "" : + apiToken + } + + /// Convert environment string to MistKit Environment + func cloudKitEnvironment() -> MistKit.Environment { + environment == "production" ? .production : .development + } +} + +// MARK: - Main Command Group + @main struct MistDemo { @MainActor @@ -64,6 +89,13 @@ struct MistDemo { } return } + + router.middlewares.add( + FileMiddleware( + resourcesPath, + searchForIndexHtml: true + ) + ) // Check if a command was specified if let commandName = parser.parseCommandName() { diff --git a/Examples/MistDemo/Sources/MistDemo/Resources/index.html b/Examples/MistDemo/Sources/MistDemo/Resources/index.html index 9168e359..473d46dd 100644 --- a/Examples/MistDemo/Sources/MistDemo/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemo/Resources/index.html @@ -161,6 +161,72 @@

MistKit CloudKit Example

console.error('CloudKit configuration error:', error); } + // Intercept fetch/XHR to capture web auth tokens from CloudKit API responses + (function setupNetworkInterception() { + // Intercept fetch + const originalFetch = window.fetch; + window.fetch = function(...args) { + const url = args[0]; + console.log('Fetch request to:', url); + + return originalFetch.apply(this, args).then(async response => { + // Check if this is a CloudKit API response + if (url && url.includes('apple-cloudkit.com')) { + const clonedResponse = response.clone(); + try { + const data = await clonedResponse.json(); + console.log('CloudKit API response:', data); + + // Check for token in response + if (data && data.ckWebAuthToken) { + console.log('โœ… Token captured from fetch response!'); + webAuthToken = data.ckWebAuthToken; + window.cloudKitWebAuthToken = data.ckWebAuthToken; + if (tokenPromiseResolve) { + tokenPromiseResolve(data.ckWebAuthToken); + } + } + } catch (e) { + // Not JSON or parsing error - ignore + } + } + return response; + }); + }; + + // Intercept XMLHttpRequest + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function(method, url, ...rest) { + this._url = url; + return originalOpen.apply(this, [method, url, ...rest]); + }; + + XMLHttpRequest.prototype.send = function(...args) { + this.addEventListener('load', function() { + if (this._url && this._url.includes('apple-cloudkit.com')) { + try { + const data = JSON.parse(this.responseText); + if (data && data.ckWebAuthToken) { + console.log('โœ… Token captured from XHR response!'); + webAuthToken = data.ckWebAuthToken; + window.cloudKitWebAuthToken = data.ckWebAuthToken; + if (tokenPromiseResolve) { + tokenPromiseResolve(data.ckWebAuthToken); + } + } + } catch (e) { + // Not JSON - ignore + } + } + }); + return originalSend.apply(this, args); + }; + + console.log('Network interception enabled'); + })(); + const container = CloudKit.getDefaultContainer(); const statusDiv = document.getElementById('status'); const userInfoDiv = document.getElementById('user-info'); @@ -179,24 +245,56 @@

MistKit CloudKit Example

// Listen for web auth token from CloudKit window.addEventListener('message', function(event) { - console.log('Received postMessage:', event.data); - if (event.data && (event.data.ckWebAuthToken || event.data.ckSession)) { - // CloudKit sends the token as either ckWebAuthToken or ckSession - webAuthToken = event.data.ckWebAuthToken || event.data.ckSession; - console.log('Web Auth Token received:', webAuthToken); - - // Resolve any pending token promise + console.log('=== postMessage Received ==='); + console.log('Origin:', event.origin); + console.log('Data type:', typeof event.data); + console.log('Data:', event.data); + + // CloudKit auth typically comes from icloud.com + const validOrigins = [ + 'https://www.icloud.com', + 'https://setup.icloud.com', + 'https://api.apple-cloudkit.com', + window.location.origin // Allow same-origin messages + ]; + + if (!validOrigins.includes(event.origin)) { + console.log('Ignoring message from unknown origin:', event.origin); + return; + } + + let token = null; + + // Strategy 1: Plain string token (format: 158__54__...) + if (typeof event.data === 'string' && event.data.match(/^158__\d+__/)) { + token = event.data; + console.log('Token format: plain string'); + } + // Strategy 2: Object with ckWebAuthToken property (official format) + else if (event.data && typeof event.data === 'object') { + token = event.data.ckWebAuthToken || + event.data.ckSession || + event.data.webAuthToken || + event.data.token; + console.log('Token format: object property'); + } + + if (token) { + console.log('โœ… Web Auth Token captured!'); + console.log('Token preview:', token.substring(0, 30) + '...'); + webAuthToken = token; + window.cloudKitWebAuthToken = token; // Store globally + if (tokenPromiseResolve) { - tokenPromiseResolve(webAuthToken); + tokenPromiseResolve(token); tokenPromiseResolve = null; tokenPromiseReject = null; } - + // Debug the condition check console.log('PostMessage check - currentUserIdentity:', currentUserIdentity); - console.log('PostMessage check - webAuthToken exists:', !!webAuthToken); console.log('PostMessage check - authenticationInProgress:', authenticationInProgress); - + // If we have user identity and token, and auth not already in progress if (currentUserIdentity && webAuthToken && !authenticationInProgress) { console.log('All conditions met, starting authentication...'); @@ -205,6 +303,8 @@

MistKit CloudKit Example

} else { console.log('Conditions not met, waiting...'); } + } else { + console.log('No token found in postMessage data'); } }); @@ -220,178 +320,56 @@

MistKit CloudKit Example

} async function handleAuthentication(userIdentity) { - console.log('User signed in:', userIdentity); + console.log('=== Authentication Successful ==='); + console.log('User Identity:', userIdentity); currentUserIdentity = userIdentity; - + authenticationInProgress = false; + + // Update UI + showStatus('Signed in successfully! Waiting for web auth token...', false); + updateSignInState(true); + // Check if we already have a token from postMessage if (webAuthToken && !authenticationInProgress) { - console.log('Token already available from postMessage, starting authentication...'); + console.log('Token already available, sending to server...'); authenticationInProgress = true; await handleAuthenticationWithToken(userIdentity, webAuthToken); - return; // Exit early, don't do the other detection attempts + return; } - - // Check localStorage for persisted CloudKit session data - try { - console.log('Checking localStorage for CloudKit session...'); - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.includes('cloudkit') || key && key.includes('ck')) { - const value = localStorage.getItem(key); - console.log(`LocalStorage ${key}:`, value); - - // Try to parse the value and look for session tokens - try { - const parsed = JSON.parse(value); - if (parsed && (parsed.ckSession || parsed.ckWebAuthToken || parsed.webAuthToken)) { - const token = parsed.ckSession || parsed.ckWebAuthToken || parsed.webAuthToken; - console.log('Found token in localStorage:', token); - webAuthToken = token; - authenticationInProgress = true; - await handleAuthenticationWithToken(userIdentity, token); - return; - } - } catch (parseError) { - // Value might not be JSON, that's fine - } - } - } - console.log('No CloudKit session found in localStorage'); - } catch (localStorageError) { - console.log('Error checking localStorage:', localStorageError); - } - - // Check cookies for CloudKit session data + + // Create a promise that will be resolved when the token is received + const tokenPromise = new Promise((resolve, reject) => { + tokenPromiseResolve = resolve; + tokenPromiseReject = reject; + + // Timeout after 10 seconds instead of 5 + setTimeout(() => { + reject(new Error('Timeout waiting for web auth token after 10 seconds')); + }, 10000); + }); + + // The token should arrive via postMessage or network interception + // Wait up to 10 seconds for token try { - console.log('Checking cookies for CloudKit session...'); - const cookies = document.cookie.split(';'); - for (const cookie of cookies) { - const [name, value] = cookie.trim().split('='); - if (name && (name.includes('cloudkit') || name.includes('ck') || name.includes('iCloud'))) { - console.log(`Cookie ${name}:`, value?.substring(0, 100) + '...'); - - // The CloudKit cookie appears to be the token directly - if (value && value.startsWith('158__54__')) { - console.log('Found CloudKit token in cookie:', name); - webAuthToken = decodeURIComponent(value); - authenticationInProgress = true; - await handleAuthenticationWithToken(userIdentity, webAuthToken); - return; - } - } - } - console.log('No CloudKit session found in cookies'); - } catch (cookieError) { - console.log('Error checking cookies:', cookieError); + const token = await tokenPromise; + console.log('โœ… Token received, sending to server...'); + await handleAuthenticationWithToken(userIdentity, token); + } catch (error) { + console.error('Token wait timeout or error:', error); + showStatus('Token capture failed. Check browser console for details.', true); + + // Provide manual extraction instructions + console.log('=== MANUAL TOKEN EXTRACTION ==='); + console.log('1. Open browser DevTools > Application > Cookies'); + console.log('2. Look for cookies from apple-cloudkit.com or icloud.com'); + console.log('3. Find a cookie starting with 158__54__'); + console.log('4. Copy the value and set: window.cloudKitWebAuthToken = "your-token"'); + console.log('5. Then call: handleAuthenticationWithToken(container.userIdentity, window.cloudKitWebAuthToken)'); } - - // Always try to get fresh token for each authentication - // (don't rely on cached webAuthToken from previous runs) - showStatus('Retrieving authentication token...'); - showLoading(true); - - try { - // Create a promise that will be resolved when the token is received - const tokenPromise = new Promise((resolve, reject) => { - tokenPromiseResolve = resolve; - tokenPromiseReject = reject; - - // Set a timeout in case the token doesn't arrive - setTimeout(() => { - if (tokenPromiseReject) { - tokenPromiseReject(new Error('Timeout waiting for authentication token')); - tokenPromiseResolve = null; - tokenPromiseReject = null; - } - }, 5000); // 5 second timeout - }); - - // Try multiple approaches to trigger the web auth token - - // Approach 1: Try to access the session and internal auth state - try { - console.log('Attempting to access CloudKit session...'); - - // Check various potential locations for the web auth token - const possibleTokenLocations = [ - container.session?.webAuthToken, - container.session?.ckWebAuthToken, - container.session?.authToken, - container._session?.webAuthToken, - container._auth?.webAuthToken, - container._auth?._ckSession, // This is where it actually is! - container._ckSession - ]; - - for (let i = 0; i < possibleTokenLocations.length; i++) { - const token = possibleTokenLocations[i]; - if (token) { - console.log(`Web Auth Token found at location ${i}:`, token); - webAuthToken = token; - await handleAuthenticationWithToken(userIdentity, webAuthToken); - return; - } - } - - console.log('Container object keys:', Object.keys(container)); - console.log('Container session:', container.session); - } catch (sessionError) { - console.log('Could not access session directly:', sessionError); - } - - // Approach 2: Try to get current user identity (this might trigger token) - try { - console.log('Attempting to get current user...'); - const currentUser = await container.getCurrentUserIdentity(); - console.log('Current user identity:', currentUser); - } catch (userInfoError) { - console.log('Current user fetch failed:', userInfoError); - } - - // Approach 3: Try database query with a valid record type - try { - console.log('Attempting database query...'); - const database = container.getDatabaseWithDatabaseScope(CloudKit.DatabaseScope.PRIVATE); - - // Try to perform a simple query - this should trigger the auth token - await database.performQuery({ - recordType: 'Users' // Use a more standard record type - }); - } catch (queryError) { - console.log('Database query failed (expected):', queryError); - // This is expected to fail, but might trigger the token - } - - // Approach 4: Try to force a fresh authentication by signing out and back in - try { - console.log('Attempting to force fresh authentication...'); - await container.signOut(); - - // Wait a moment for sign out to complete - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Sign back in - const freshUserIdentity = await container.setUpAuth(); - if (freshUserIdentity) { - console.log('Fresh authentication successful'); - // The postMessage should be triggered during fresh sign-in - } - } catch (freshAuthError) { - console.log('Fresh authentication failed:', freshAuthError); - } - + } - - // Wait for the token from any of the above operations - webAuthToken = await tokenPromise; - console.log('Web Auth Token received:', webAuthToken); - await handleAuthenticationWithToken(userIdentity, webAuthToken); - - } catch (error) { - console.log('All authentication attempts failed:', error); - showStatus('Failed to retrieve authentication token. Please try signing out and back in.', true); - showLoading(false); - } + function updateSignInState(isSignedIn) { + signoutButton.style.display = isSignedIn ? 'inline-block' : 'none'; } async function handleAuthenticationWithToken(userIdentity, token) { @@ -617,6 +595,55 @@

Raw Response

// Initialize CloudKit when page loads initializeCloudKit(); + + // Expose debugging helpers globally + window.mistKitDebug = { + container: () => CloudKit.getDefaultContainer(), + token: () => window.cloudKitWebAuthToken || webAuthToken, + setToken: (token) => { + window.cloudKitWebAuthToken = token; + webAuthToken = token; + console.log('Token manually set'); + }, + sendToServer: () => { + const container = CloudKit.getDefaultContainer(); + if (container && container.userIdentity) { + handleAuthenticationWithToken(container.userIdentity, window.cloudKitWebAuthToken || webAuthToken); + } else { + console.error('Not signed in'); + } + }, + inspectContainer: () => { + const container = CloudKit.getDefaultContainer(); + console.log('Container:', container); + console.log('Container properties:', Object.keys(container)); + console.log('User identity:', container.userIdentity); + + // Try to find token in various places + const locations = { + 'session.webAuthToken': container.session?.webAuthToken, + '_auth.webAuthToken': container._auth?.webAuthToken, + '_auth._ckSession': container._auth?._ckSession, + 'window.cloudKitWebAuthToken': window.cloudKitWebAuthToken, + 'webAuthToken variable': webAuthToken + }; + + console.log('Checked token locations:', locations); + + for (const [path, value] of Object.entries(locations)) { + if (value) { + console.log(`โœ… Found at ${path}:`, value); + } + } + } + }; + + console.log('MistKit Debug helpers available:'); + console.log(' mistKitDebug.container() - Get CloudKit container'); + console.log(' mistKitDebug.token() - Get current token'); + console.log(' mistKitDebug.setToken(tok) - Manually set token'); + console.log(' mistKitDebug.sendToServer() - Send token to server'); + console.log(' mistKitDebug.inspectContainer() - Inspect container for token'); diff --git a/Examples/MistDemo/TESTING_STATUS.md b/Examples/MistDemo/TESTING_STATUS.md new file mode 100644 index 00000000..90a6b656 --- /dev/null +++ b/Examples/MistDemo/TESTING_STATUS.md @@ -0,0 +1,137 @@ +# MistKit Branch 199 Testing Status + +## Current State + +We're on branch `199-cloudkit-api-coverage` testing three new CloudKit operations: +- `lookupZones` - Look up specific zones by ID +- `fetchRecordChanges` - Get record changes with sync tokens +- `uploadAssets` - Upload binary assets to CloudKit + +## What's Been Completed โœ… + +### 1. Code Implementation +- โœ… All three new API operations are implemented in MistKit +- โœ… Integration test suite created in `Examples/MistDemo` +- โœ… CLI commands created: `lookup-zones`, `fetch-changes`, `upload-asset`, `test-integration` +- โœ… Modified integration tests to use private database with web authentication +- โœ… Built successfully without compilation errors + +### 2. Authentication Infrastructure +- โœ… Updated integration tests to support both API token + web auth token +- โœ… Created `AdaptiveTokenManager` setup for private database access +- โœ… Added environment variable support for `CLOUDKIT_WEBAUTH_TOKEN` +- โœ… Implemented web authentication server in `auth` command + +## Current Status โš ๏ธ + +### Web Authentication Implementation Complete - Container Config Issue + +**What Works**: โœ… +- Enhanced postMessage listener with origin verification and multiple token formats +- Network request interception (fetch/XHR) for token capture +- Debugging helpers (`mistKitDebug.inspectContainer()`, etc.) +- Successfully extracted web auth token manually: `158__54__...` (token format verified) +- Static `index.html` with all improvements ready to serve + +**Current Blocker**: โŒ +- **421 Misdirected Request** error when making CloudKit API calls +- This is a **container configuration issue**, not a token extraction problem +- Error occurs even with just API token on public database (no web auth involved) + +**Likely Causes**: +1. Container `iCloud.com.brightdigit.MistDemo` may not exist or not be configured +2. Web services not enabled for development environment in CloudKit Dashboard +3. API token may be for wrong container or expired +4. Need to verify container setup at https://icloud.developer.apple.com/dashboard/ + +**Token Extraction Improvements Made**: +- โœ… Enhanced postMessage listener with origin validation (`icloud.com`, `apple-cloudkit.com`) +- โœ… Support for multiple token formats (plain string `158__54__...`, object properties) +- โœ… Network interception to capture tokens from API responses (fallback method) +- โœ… Extended timeout from 5 to 10 seconds +- โœ… Manual extraction instructions if automatic capture fails +- โœ… Global debugging helpers for browser console testing + +## CloudKit Authentication Requirements + +| Database | Operation | Auth Method Required | +|----------|-----------|---------------------| +| Public | Read | API Token OR Server-to-Server | +| Public | Write | API Token + Web Auth Token OR Server-to-Server | +| Private | Any | API Token + Web Auth Token (user must sign in) | + +## Next Steps to Complete Testing + +### IMMEDIATE: Fix Container Configuration (Required) +1. **Verify container exists** at https://icloud.developer.apple.com/dashboard/ + - Confirm `iCloud.com.brightdigit.MistDemo` is created + - Check that development environment is enabled + - Verify web services are configured +2. **Regenerate API token** if needed from CloudKit Console +3. **Deploy schema** (`schema.ckdb`) to development environment +4. **Test with correct container/token** once verified + +### THEN: Test Web Authentication Flow +1. **Start auth server**: `swift run mistdemo auth --api-token YOUR_TOKEN` +2. **Sign in with Apple ID** in browser +3. **Verify token capture** via enhanced postMessage listener +4. **Test integration suite** with captured token + +### Alternative: Use Different Container +If `iCloud.com.brightdigit.MistDemo` is not available: +1. **Create new container** in CloudKit Dashboard +2. **Enable web services** and generate API token +3. **Update container ID** in `index.html` and test commands +4. **Deploy test schema** to new container + +## Test Commands Ready to Use + +Once we have a working web auth token: + +```bash +# Test integration suite +swift run mistdemo test-integration \ + --api-token dbc855f4034e6f4ff0c71bd4b59f251f0f21b4aff213a446f8c76ee70b670193 \ + --web-auth-token YOUR_TOKEN \ + --database private + +# Test individual operations +swift run mistdemo lookup-zones --web-auth-token YOUR_TOKEN +swift run mistdemo fetch-changes --web-auth-token YOUR_TOKEN +swift run mistdemo upload-asset file.jpg --web-auth-token YOUR_TOKEN +``` + +## Files Modified + +### Core Implementation +- `Sources/MistKit/Service/CloudKitService+Operations.swift` - New API operations +- `Sources/MistKit/Service/CloudKitService+WriteOperations.swift` - Asset upload + +### Test Infrastructure +- `Examples/MistDemo/Sources/MistDemo/Commands/TestIntegration.swift` - Updated for private DB +- `Examples/MistDemo/Sources/MistDemo/Integration/IntegrationTestRunner.swift` - Web auth support +- `Examples/MistDemo/Sources/MistDemo/MistDemo.swift` - Web authentication server + +### New Commands +- `Examples/MistDemo/Sources/MistDemo/Commands/LookupZones.swift` +- `Examples/MistDemo/Sources/MistDemo/Commands/FetchChanges.swift` +- `Examples/MistDemo/Sources/MistDemo/Commands/UploadAsset.swift` + +### Web Authentication Enhancements (Latest) +- `Examples/MistDemo/Sources/MistDemo/Resources/index.html` - Enhanced token extraction: + - Phase 1: Enhanced postMessage listener with origin verification + - Phase 2: Network request interception (fetch/XHR) + - Phase 3: Simplified handleAuthentication() with 10s timeout + - Phase 5: Browser console debugging helpers (`mistKitDebug.*`) +- `Examples/MistDemo/Sources/MistDemo/MistDemo.swift` - Updated sendTokenToServer() with retry logic + +## Container Configuration + +- **Container ID**: `iCloud.com.brightdigit.MistDemo` +- **API Token**: `dbc855f4034e6f4ff0c71bd4b59f251f0f21b4aff213a446f8c76ee70b670193` +- **Environment**: `development` +- **Schema**: `schema.ckdb` (needs to be deployed) + +## Priority + +**HIGH**: Get web authentication working to test the new APIs with private database access, which is the most realistic use case for these operations. \ No newline at end of file diff --git a/Package.swift b/Package.swift index 464e8021..1c718ca4 100644 --- a/Package.swift +++ b/Package.swift @@ -62,12 +62,12 @@ let swiftSettings: [SwiftSetting] = [ .enableExperimentalFeature("WarnUnsafeReflection"), // Enhanced compiler checking - // .unsafeFlags([ - // // Warn about functions with >100 lines - // "-Xfrontend", "-warn-long-function-bodies=100", - // // Warn about slow type checking expressions - // "-Xfrontend", "-warn-long-expression-type-checking=100" - // ]) + .unsafeFlags([ + // Warn about functions with >100 lines + "-Xfrontend", "-warn-long-function-bodies=100", + // Warn about slow type checking expressions + "-Xfrontend", "-warn-long-expression-type-checking=100" + ]) ] let package = Package( diff --git a/swift-build-issue-proposal.md b/swift-build-issue-proposal.md new file mode 100644 index 00000000..b22de216 --- /dev/null +++ b/swift-build-issue-proposal.md @@ -0,0 +1,220 @@ +# Support Swift Testing Framework Detection for WASM Builds + +## Problem Description + +When running WASM tests with `swift-build`, tests using the Swift Testing framework (`import Testing`) are not being discovered and executed, resulting in "0 tests" output: + +``` +Searching for Wasm test binaries... +Found test binaries: +.build/wasm32-unknown-wasip1/debug/MistKitPackageTests.xctest + +Running tests with WasmKit: .build/wasm32-unknown-wasip1/debug/MistKitPackageTests.xctest +Test Suite 'All tests' started at 1970-01-01 00:03:45.952 +Test Suite 'debug.xctest' started at 1970-01-01 00:03:46.013 +Test Suite 'debug.xctest' passed at 1970-01-01 00:03:46.014 + Executed 0 tests, with 0 failures (0 unexpected) in 0.0 (0.0) seconds +``` + +## Root Cause + +The action currently runs WASM test binaries without specifying the testing library: + +```bash +wasmkit run MistKitPackageTests.xctest +``` + +However, Swift Testing tests on WASM require an explicit flag: + +```bash +wasmkit run MistKitPackageTests.xctest -- --testing-library swift-testing +``` + +When this flag is provided, tests execute correctly: + +``` +โ—‡ Test run started. +โ†ณ Testing Library Version: 6.2.3 (48a471ab313e858) +โ†ณ Target Platform: wasm32-unknown-wasip +โ—‡ Suite "Token Manager - Authentication Method" started. +โœ” Test "AuthenticationMethod computed properties" passed after 0.016 seconds. +... +Test run with 487 tests passed after 28.891 seconds. +``` + +## Background: WASM Testing Requirements + +Unlike native platforms where Swift Package Manager auto-detects the testing framework, WASM requires explicit specification: + +- **Swift Testing tests** (`@Test`, `@Suite`): Require `--testing-library swift-testing` +- **XCTest tests** (`XCTestCase` subclasses): Run without flag or with `--testing-library xctest` +- **Mixed tests**: Currently problematic on WASM (would require multiple test runs) + +Reference: [Swift Testing WASI Documentation](https://github.com/swiftlang/swift-testing/blob/main/Documentation/WASI.md) + +## Proposed Solution + +Add automatic testing framework detection with an optional override parameter. + +### New Action Parameters + +```yaml +inputs: + wasm-testing-library: + description: 'Testing framework for WASM tests (auto, swift-testing, xctest, both)' + required: false + default: 'auto' + + wasm-swift-test-flags: + description: 'Additional flags to pass to WASM test runner' + required: false +``` + +### Detection Algorithm + +When `wasm-testing-library: 'auto'` (default): + +1. **Scan test sources** for imports: + ```bash + # Check for Swift Testing + grep -r "import Testing" Tests/ --include="*.swift" -q + HAS_SWIFT_TESTING=$? + + # Check for XCTest + grep -r "import XCTest" Tests/ --include="*.swift" -q + HAS_XCTEST=$? + ``` + +2. **Determine testing library**: + - If only Swift Testing found โ†’ Use `--testing-library swift-testing` + - If only XCTest found โ†’ Use `--testing-library xctest` (or no flag) + - If both found โ†’ Use `both` mode (run twice) + - If neither found โ†’ Use `--testing-library swift-testing` (Swift 6+ default) + +3. **Execute tests accordingly**: + ```bash + case $TESTING_LIBRARY in + swift-testing) + wasmkit run $TEST_BINARY -- --testing-library swift-testing + ;; + xctest) + wasmkit run $TEST_BINARY -- --testing-library xctest + ;; + both) + echo "Running Swift Testing tests..." + wasmkit run $TEST_BINARY -- --testing-library swift-testing + echo "Running XCTest tests..." + wasmkit run $TEST_BINARY -- --testing-library xctest + ;; + esac + ``` + +### Manual Override Examples + +For projects that know their testing framework: + +```yaml +# Force Swift Testing +- uses: brightdigit/swift-build@1.5.0-beta.2 + with: + type: wasm + wasm-testing-library: swift-testing + +# Force XCTest +- uses: brightdigit/swift-build@1.5.0-beta.2 + with: + type: wasm + wasm-testing-library: xctest + +# Force both (run twice) +- uses: brightdigit/swift-build@1.5.0-beta.2 + with: + type: wasm + wasm-testing-library: both + +# Pass additional test flags +- uses: brightdigit/swift-build@1.5.0-beta.2 + with: + type: wasm + wasm-swift-test-flags: --filter "TestSuiteName" +``` + +## Alternative Approach: Package Manifest Detection + +Instead of scanning sources, parse `Package.swift` for test target dependencies: + +```swift +.testTarget( + name: "MyTests", + dependencies: ["MyPackage", "Testing"] // Swift Testing +) + +.testTarget( + name: "MyTests", + dependencies: ["MyPackage", "XCTest"] // XCTest +) +``` + +This approach is more reliable but requires parsing Swift code or running SPM commands. + +## Benefits + +1. **Backward compatible**: Default `auto` mode works for existing projects +2. **Zero configuration**: Automatically detects and uses correct framework +3. **Future-proof**: Supports mixed testing scenarios +4. **Performance**: Can skip tests with explicit `wasm-testing-library: none` +5. **Flexibility**: Manual override for edge cases + +## Implementation Checklist + +- [ ] Add new input parameters to `action.yml` +- [ ] Implement testing framework detection logic +- [ ] Update WASM test execution script to pass appropriate flags +- [ ] Add tests for auto-detection (Swift Testing, XCTest, mixed, none) +- [ ] Update README with new parameters and examples +- [ ] Add migration guide for existing users +- [ ] Consider adding warning when mixed tests detected + +## Related Issues + +- #72: Swap Wasmtime for WasmKit (completed) +- #30: Add WASM Support (foundation) +- #68: WASM code coverage is not supported (separate concern) + +## Testing + +This issue was discovered while testing [MistKit](https://github.com/brightdigit/MistKit) which uses Swift Testing exclusively. The tests work correctly when run manually with: + +```bash +swift build --swift-sdk swift-6.2.3-RELEASE_wasm --build-tests \ + -Xcc -D_WASI_EMULATED_SIGNAL \ + -Xcc -D_WASI_EMULATED_MMAN \ + -Xlinker -lwasi-emulated-signal \ + -Xlinker -lwasi-emulated-mman + +wasmkit run .build/wasm32-unknown-wasip1/debug/MistKitPackageTests.xctest -- --testing-library swift-testing +``` + +Result: All 487 tests pass successfully. + +## Additional Context + +- Swift Testing became the default testing framework in Swift 6.0 +- Many modern projects are migrating from XCTest to Swift Testing +- WASM support is critical for cross-platform Swift development +- This issue affects any project using Swift Testing with WASM builds + +## Questions for Maintainers + +1. Is source scanning an acceptable approach, or would you prefer Package.swift parsing? +2. Should the default be `auto` or require explicit configuration? +3. For mixed tests, should we run both or fail with an error message? +4. Should we emit a GitHub Actions warning when mixed tests are detected? + +--- + +**Environment:** +- swift-build: 1.5.0-beta.2 +- Swift: 6.2.3 +- WasmKit: 0.1.6 +- Platform: Ubuntu (GitHub Actions)