From f4a41f83552bed7b7f2ebc5bc9a0f9f1dcefb3a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 May 2025 19:12:21 +0200 Subject: [PATCH] Add comprehensive Swift unit tests and enhanced CLI testing - Add unit test templates for all Swift components: - ApplicationFinderTests: Test app discovery and fuzzy matching - WindowManagerTests: Test window listing and filtering - PermissionsCheckerTests: Test permission detection - ImageCommandTests: Test command parsing and validation - ListCommandTests: Test list command variations - Enhance release script with thorough CLI testing: - Test all commands with various arguments - Validate JSON output structure - Test error handling for invalid inputs - Check permission status reporting - Add dedicated Swift CLI integration test phase - Update RELEASING.md to highlight automated checks --- RELEASING.md | 55 ++- .../ApplicationFinderTests.swift | 102 ++++ .../peekabooTests/ImageCommandTests.swift | 216 ++++++++ .../peekabooTests/ListCommandTests.swift | 250 ++++++++++ .../PermissionsCheckerTests.swift | 145 ++++++ .../peekabooTests/WindowManagerTests.swift | 174 +++++++ scripts/prepare-release.js | 463 +++++++++++++++++- 7 files changed, 1380 insertions(+), 25 deletions(-) create mode 100644 peekaboo-cli/Tests/peekabooTests/ApplicationFinderTests.swift create mode 100644 peekaboo-cli/Tests/peekabooTests/ImageCommandTests.swift create mode 100644 peekaboo-cli/Tests/peekabooTests/ListCommandTests.swift create mode 100644 peekaboo-cli/Tests/peekabooTests/PermissionsCheckerTests.swift create mode 100644 peekaboo-cli/Tests/peekabooTests/WindowManagerTests.swift diff --git a/RELEASING.md b/RELEASING.md index 72967f8..6a66fb6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,49 +2,56 @@ This document outlines the steps to release a new version of the `@steipete/peekaboo-mcp` NPM package. -## Pre-Release Checklist +## Automated Release Preparation -1. **Ensure Main Branch is Up-to-Date:** - - Pull the latest changes from the main branch (`main` or `master`). - - `git pull origin main` +The project includes an automated release preparation script that performs comprehensive checks before release. Run it with: -2. **Create a Release Branch (Optional but Recommended):** - - Create a new branch for the release, e.g., `release/v1.0.0-beta.3`. - - `git checkout -b release/vX.Y.Z` +```bash +npm run prepare-release +``` -3. **Update Version Number:** +This script performs the following checks: +- **Git Status**: Ensures you're on the main branch with no uncommitted changes +- **Required Fields**: Validates all required fields in package.json +- **Dependencies**: Checks for missing or outdated dependencies +- **Security Audit**: Runs npm audit to check for vulnerabilities +- **Version Availability**: Confirms the version isn't already published +- **Version Consistency**: Ensures package.json and package-lock.json versions match +- **Changelog Entry**: Verifies CHANGELOG.md has an entry for the current version +- **TypeScript**: Compiles and runs tests +- **TypeScript Declarations**: Verifies .d.ts files are generated +- **Swift**: Runs format, lint, and tests +- **Build Verification**: Builds everything and verifies the package +- **Package Size**: Warns if package exceeds 2MB +- **MCP Server Smoke Test**: Tests the server with a simple JSON-RPC request + +If all checks pass, follow the manual steps below. + +## Manual Pre-Release Steps + +1. **Update Version Number:** - Decide on the new semantic version number (e.g., `1.0.0-beta.3`, `1.0.0`, `1.1.0`). - Update the `version` field in `package.json`. -4. **Update Documentation:** +2. **Update Documentation:** - **`README.md`**: Ensure it accurately reflects the latest features, installation instructions, and any breaking changes. - **`docs/spec.md`**: If there are changes to tool schemas or server behavior, update the detailed specification. - Any other relevant documentation. -5. **Update `CHANGELOG.md`:** +3. **Update `CHANGELOG.md`:** - Add a new section for the upcoming release version (e.g., `## [1.0.0-beta.3] - YYYY-MM-DD`). - List all notable changes (Added, Changed, Fixed, Removed, Deprecated, Security) under this version. - Replace `YYYY-MM-DD` with the current date. -6. **Run All Tests:** - - Ensure all unit, integration, and E2E tests are passing. - - `npm test` (or `npm run test:all` if that's more comprehensive for your setup). +4. **Run Release Preparation:** + - Run `npm run prepare-release` to ensure everything is ready. + - Fix any issues identified by the script. -7. **Build the Project:** - - Run the build script to compile TypeScript and the Swift CLI. - - `npm run build:all` (as defined in `package.json`). - -8. **Commit Changes:** +5. **Commit Changes:** - Commit all changes related to the version bump, documentation, and changelog. - `git add .` - `git commit -m "Prepare release vX.Y.Z"` -9. **Merge to Main Branch (If Using a Release Branch):** - - Merge the release branch back into the main branch. - - `git checkout main` - - `git merge release/vX.Y.Z --no-ff` (using `--no-ff` creates a merge commit, which can be useful for tracking releases). - - `git push origin main` - ## Publishing to NPM 1. **NPM Publish Dry Run:** diff --git a/peekaboo-cli/Tests/peekabooTests/ApplicationFinderTests.swift b/peekaboo-cli/Tests/peekabooTests/ApplicationFinderTests.swift new file mode 100644 index 0000000..0e5a9e6 --- /dev/null +++ b/peekaboo-cli/Tests/peekabooTests/ApplicationFinderTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import peekaboo + +final class ApplicationFinderTests: XCTestCase { + var applicationFinder: ApplicationFinder! + + override func setUp() { + super.setUp() + applicationFinder = ApplicationFinder() + } + + override func tearDown() { + applicationFinder = nil + super.tearDown() + } + + // MARK: - findRunningApplication Tests + + func testFindRunningApplicationExactMatch() throws { + // Test finding an app that should always be running on macOS + let result = try applicationFinder.findRunningApplication(named: "Finder") + + XCTAssertNotNil(result) + XCTAssertEqual(result?.localizedName, "Finder") + XCTAssertEqual(result?.bundleIdentifier, "com.apple.finder") + } + + func testFindRunningApplicationCaseInsensitive() throws { + // Test case-insensitive matching + let result = try applicationFinder.findRunningApplication(named: "finder") + + XCTAssertNotNil(result) + XCTAssertEqual(result?.localizedName, "Finder") + } + + func testFindRunningApplicationByBundleIdentifier() throws { + // Test finding by bundle identifier + let result = try applicationFinder.findRunningApplication(named: "com.apple.finder") + + XCTAssertNotNil(result) + XCTAssertEqual(result?.bundleIdentifier, "com.apple.finder") + } + + func testFindRunningApplicationNotFound() { + // Test app not found error + XCTAssertThrowsError(try applicationFinder.findRunningApplication(named: "NonExistentApp12345")) { error in + guard let captureError = error as? CaptureError else { + XCTFail("Expected CaptureError") + return + } + XCTAssertEqual(captureError, .appNotFound) + } + } + + func testFindRunningApplicationPartialMatch() throws { + // Test partial name matching + let result = try applicationFinder.findRunningApplication(named: "Find") + + // Should find Finder as closest match + XCTAssertNotNil(result) + XCTAssertEqual(result?.localizedName, "Finder") + } + + // MARK: - Fuzzy Matching Tests + + func testFuzzyMatchingScore() { + // Test the fuzzy matching algorithm + let finder = "Finder" + + // Exact match should have highest score + XCTAssertEqual(applicationFinder.fuzzyMatch("Finder", with: finder), 1.0) + + // Case differences should still score high + XCTAssertGreaterThan(applicationFinder.fuzzyMatch("finder", with: finder), 0.8) + + // Partial matches should score lower but still match + XCTAssertGreaterThan(applicationFinder.fuzzyMatch("Find", with: finder), 0.5) + XCTAssertLessThan(applicationFinder.fuzzyMatch("Find", with: finder), 0.9) + + // Completely different should score very low + XCTAssertLessThan(applicationFinder.fuzzyMatch("Safari", with: finder), 0.3) + } + + func testFuzzyMatchingWithSpaces() { + // Test matching with spaces and special characters + let appName = "Google Chrome" + + // Various ways users might type Chrome + XCTAssertGreaterThan(applicationFinder.fuzzyMatch("chrome", with: appName), 0.5) + XCTAssertGreaterThan(applicationFinder.fuzzyMatch("google", with: appName), 0.5) + XCTAssertGreaterThan(applicationFinder.fuzzyMatch("googlechrome", with: appName), 0.7) + } + + // MARK: - Performance Tests + + func testFindApplicationPerformance() throws { + // Test that finding an app completes in reasonable time + measure { + _ = try? applicationFinder.findRunningApplication(named: "Finder") + } + } +} \ No newline at end of file diff --git a/peekaboo-cli/Tests/peekabooTests/ImageCommandTests.swift b/peekaboo-cli/Tests/peekabooTests/ImageCommandTests.swift new file mode 100644 index 0000000..94bc6b4 --- /dev/null +++ b/peekaboo-cli/Tests/peekabooTests/ImageCommandTests.swift @@ -0,0 +1,216 @@ +import XCTest +import ArgumentParser +@testable import peekaboo + +final class ImageCommandTests: XCTestCase { + + // MARK: - Command Parsing Tests + + func testImageCommandParsing() throws { + // Test basic command parsing + let command = try ImageCommand.parse([]) + + // Verify defaults + XCTAssertEqual(command.mode, .activeWindow) + XCTAssertEqual(command.format, .png) + XCTAssertNil(command.output) + XCTAssertNil(command.app) + XCTAssertNil(command.windowId) + XCTAssertFalse(command.includeWindowFrame) + XCTAssertEqual(command.quality, 90) + } + + func testImageCommandWithScreenMode() throws { + // Test screen capture mode + let command = try ImageCommand.parse(["--mode", "screen"]) + + XCTAssertEqual(command.mode, .screen) + } + + func testImageCommandWithAppSpecifier() throws { + // Test app-specific capture + let command = try ImageCommand.parse([ + "--mode", "app", + "--app", "Finder" + ]) + + XCTAssertEqual(command.mode, .app) + XCTAssertEqual(command.app, "Finder") + } + + func testImageCommandWithWindowId() throws { + // Test window ID capture + let command = try ImageCommand.parse([ + "--mode", "window", + "--window-id", "123" + ]) + + XCTAssertEqual(command.mode, .window) + XCTAssertEqual(command.windowId, 123) + } + + func testImageCommandWithOutput() throws { + // Test output path specification + let outputPath = "/tmp/test-image.png" + let command = try ImageCommand.parse([ + "--output", outputPath + ]) + + XCTAssertEqual(command.output, outputPath) + } + + func testImageCommandWithFormat() throws { + // Test JPEG format + let command = try ImageCommand.parse([ + "--format", "jpg", + "--quality", "85" + ]) + + XCTAssertEqual(command.format, .jpg) + XCTAssertEqual(command.quality, 85) + } + + func testImageCommandWithJSONOutput() throws { + // Test JSON output flag + let command = try ImageCommand.parse(["--json-output"]) + + XCTAssertTrue(command.jsonOutput) + } + + // MARK: - Validation Tests + + func testImageCommandValidationMissingApp() { + // Test that app mode requires app name + XCTAssertThrowsError(try ImageCommand.parse([ + "--mode", "app" + // Missing --app parameter + ])) + } + + func testImageCommandValidationMissingWindowId() { + // Test that window mode requires window ID + XCTAssertThrowsError(try ImageCommand.parse([ + "--mode", "window" + // Missing --window-id parameter + ])) + } + + func testImageCommandValidationInvalidQuality() { + // Test quality validation + XCTAssertThrowsError(try ImageCommand.parse([ + "--quality", "150" // > 100 + ])) + + XCTAssertThrowsError(try ImageCommand.parse([ + "--quality", "-10" // < 0 + ])) + } + + // MARK: - Capture Mode Tests + + func testCaptureModeRawValues() { + // Test capture mode string values + XCTAssertEqual(CaptureMode.screen.rawValue, "screen") + XCTAssertEqual(CaptureMode.activeWindow.rawValue, "active_window") + XCTAssertEqual(CaptureMode.app.rawValue, "app") + XCTAssertEqual(CaptureMode.window.rawValue, "window") + XCTAssertEqual(CaptureMode.area.rawValue, "area") + } + + func testImageFormatRawValues() { + // Test image format values + XCTAssertEqual(ImageFormat.png.rawValue, "png") + XCTAssertEqual(ImageFormat.jpg.rawValue, "jpg") + XCTAssertEqual(ImageFormat.data.rawValue, "data") + } + + // MARK: - Helper Method Tests + + func testGenerateFilename() { + // Test filename generation with pattern + let pattern = "{app}_{mode}_{timestamp}" + let date = Date(timeIntervalSince1970: 1700000000) // Fixed date for testing + + // Mock the filename generation logic + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + let timestamp = formatter.string(from: date) + + let filename = pattern + .replacingOccurrences(of: "{app}", with: "Finder") + .replacingOccurrences(of: "{mode}", with: "window") + .replacingOccurrences(of: "{timestamp}", with: timestamp) + + XCTAssertTrue(filename.contains("Finder")) + XCTAssertTrue(filename.contains("window")) + XCTAssertTrue(filename.contains("2023-11-14")) // Date from timestamp + } + + func testBlurDetection() { + // Test blur detection threshold logic + let blurThresholds: [Double] = [0.0, 0.5, 1.0] + + for threshold in blurThresholds { + XCTAssertGreaterThanOrEqual(threshold, 0.0) + XCTAssertLessThanOrEqual(threshold, 1.0) + } + } + + // MARK: - Error Response Tests + + func testErrorResponseCreation() throws { + // Test error response structure + let error = CaptureError.appNotFound + + let errorData = SwiftCliErrorData( + error: error.rawValue, + message: error.description, + details: nil, + suggestions: [] + ) + + XCTAssertEqual(errorData.error, "APP_NOT_FOUND") + XCTAssertEqual(errorData.message, "Application not found") + } + + // MARK: - Integration Tests + + func testImageCaptureDataEncoding() throws { + // Test that ImageCaptureData can be encoded to JSON + let captureData = ImageCaptureData( + imageData: "base64data", + imageUrl: nil, + savedFile: nil, + metadata: ImageMetadata( + width: 1920, + height: 1080, + fileSize: 1024000, + format: "png", + colorSpace: "sRGB", + bitsPerPixel: 24, + capturedAt: "2023-11-14T12:00:00Z" + ), + captureMode: "screen", + targetApp: nil, + windowInfo: nil, + isBlurry: false, + blurScore: 0.1, + debugLogs: [] + ) + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + let data = try encoder.encode(captureData) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertNotNil(json) + XCTAssertEqual(json?["image_data"] as? String, "base64data") + XCTAssertEqual(json?["capture_mode"] as? String, "screen") + XCTAssertEqual(json?["is_blurry"] as? Bool, false) + + let metadata = json?["metadata"] as? [String: Any] + XCTAssertEqual(metadata?["width"] as? Int, 1920) + XCTAssertEqual(metadata?["height"] as? Int, 1080) + } +} \ No newline at end of file diff --git a/peekaboo-cli/Tests/peekabooTests/ListCommandTests.swift b/peekaboo-cli/Tests/peekabooTests/ListCommandTests.swift new file mode 100644 index 0000000..436aef0 --- /dev/null +++ b/peekaboo-cli/Tests/peekabooTests/ListCommandTests.swift @@ -0,0 +1,250 @@ +import XCTest +import ArgumentParser +@testable import peekaboo + +final class ListCommandTests: XCTestCase { + + // MARK: - Command Parsing Tests + + func testListCommandParsing() throws { + // Test basic command parsing + let command = try ListCommand.parse(["running_applications"]) + + XCTAssertEqual(command.type, .runningApplications) + XCTAssertFalse(command.jsonOutput) + } + + func testListCommandWithJSONOutput() throws { + // Test JSON output flag + let command = try ListCommand.parse(["server_status", "--json-output"]) + + XCTAssertEqual(command.type, .serverStatus) + XCTAssertTrue(command.jsonOutput) + } + + func testListCommandAllTypes() throws { + // Test all list types parse correctly + let types: [(String, ListType)] = [ + ("running_applications", .runningApplications), + ("windows", .windows), + ("server_status", .serverStatus) + ] + + for (arg, expectedType) in types { + let command = try ListCommand.parse([arg]) + XCTAssertEqual(command.type, expectedType) + } + } + + func testListCommandWithApp() throws { + // Test windows list with app filter + let command = try ListCommand.parse([ + "windows", + "--app", "Finder" + ]) + + XCTAssertEqual(command.type, .windows) + XCTAssertEqual(command.app, "Finder") + } + + func testListCommandWithWindowDetail() throws { + // Test window detail options + let command = try ListCommand.parse([ + "windows", + "--window-detail", "full" + ]) + + XCTAssertEqual(command.type, .windows) + XCTAssertEqual(command.windowDetail, .full) + } + + // MARK: - ListType Tests + + func testListTypeRawValues() { + // Test list type string values + XCTAssertEqual(ListType.runningApplications.rawValue, "running_applications") + XCTAssertEqual(ListType.windows.rawValue, "windows") + XCTAssertEqual(ListType.serverStatus.rawValue, "server_status") + } + + func testWindowDetailOptionRawValues() { + // Test window detail option values + XCTAssertEqual(WindowDetailOption.none.rawValue, "none") + XCTAssertEqual(WindowDetailOption.basic.rawValue, "basic") + XCTAssertEqual(WindowDetailOption.full.rawValue, "full") + } + + // MARK: - Data Structure Tests + + func testApplicationListDataEncoding() throws { + // Test ApplicationListData JSON encoding + let appData = ApplicationListData( + applications: [ + ApplicationInfo( + name: "Finder", + bundleIdentifier: "com.apple.finder", + processIdentifier: 123, + isActive: true + ), + ApplicationInfo( + name: "Safari", + bundleIdentifier: "com.apple.Safari", + processIdentifier: 456, + isActive: false + ) + ] + ) + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + let data = try encoder.encode(appData) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertNotNil(json) + let apps = json?["applications"] as? [[String: Any]] + XCTAssertEqual(apps?.count, 2) + + let firstApp = apps?.first + XCTAssertEqual(firstApp?["name"] as? String, "Finder") + XCTAssertEqual(firstApp?["bundle_identifier"] as? String, "com.apple.finder") + XCTAssertEqual(firstApp?["process_identifier"] as? Int, 123) + XCTAssertEqual(firstApp?["is_active"] as? Bool, true) + } + + func testWindowListDataEncoding() throws { + // Test WindowListData JSON encoding + let windowData = WindowListData( + targetApp: TargetApplicationInfo( + name: "Finder", + bundleIdentifier: "com.apple.finder", + processIdentifier: 123 + ), + windows: [ + WindowData( + windowInfo: WindowInfo( + windowID: 1001, + owningApplication: "Finder", + windowTitle: "Documents", + windowIndex: 0, + bounds: WindowBounds(x: 100, y: 200, width: 800, height: 600), + isOnScreen: true, + windowLevel: 0 + ), + hasDetails: true + ) + ] + ) + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + let data = try encoder.encode(windowData) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertNotNil(json) + + let targetApp = json?["target_app"] as? [String: Any] + XCTAssertEqual(targetApp?["name"] as? String, "Finder") + + let windows = json?["windows"] as? [[String: Any]] + XCTAssertEqual(windows?.count, 1) + + let firstWindow = windows?.first + let windowInfo = firstWindow?["window_info"] as? [String: Any] + XCTAssertEqual(windowInfo?["window_id"] as? Int, 1001) + XCTAssertEqual(windowInfo?["owning_application"] as? String, "Finder") + XCTAssertEqual(windowInfo?["window_title"] as? String, "Documents") + } + + func testServerStatusEncoding() throws { + // Test ServerStatus JSON encoding + let status = ServerStatus( + hasScreenRecordingPermission: true, + hasAccessibilityPermission: false + ) + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + let data = try encoder.encode(status) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertNotNil(json) + XCTAssertEqual(json?["has_screen_recording_permission"] as? Bool, true) + XCTAssertEqual(json?["has_accessibility_permission"] as? Bool, false) + } + + // MARK: - Window Specifier Tests + + func testWindowSpecifierFromApp() { + // Test window specifier creation from app + let specifier = WindowSpecifier.app("Finder") + + switch specifier { + case .app(let name): + XCTAssertEqual(name, "Finder") + default: + XCTFail("Expected app specifier") + } + } + + func testWindowSpecifierFromWindowId() { + // Test window specifier creation from window ID + let specifier = WindowSpecifier.windowId(123) + + switch specifier { + case .windowId(let id): + XCTAssertEqual(id, 123) + default: + XCTFail("Expected windowId specifier") + } + } + + func testWindowSpecifierActiveWindow() { + // Test active window specifier + let specifier = WindowSpecifier.activeWindow + + switch specifier { + case .activeWindow: + // Success + break + default: + XCTFail("Expected activeWindow specifier") + } + } + + // MARK: - Error Handling Tests + + func testListCommandInvalidType() { + // Test invalid list type + XCTAssertThrowsError(try ListCommand.parse(["invalid_type"])) + } + + func testListCommandMissingType() { + // Test missing list type + XCTAssertThrowsError(try ListCommand.parse([])) + } + + // MARK: - Performance Tests + + func testApplicationInfoEncodingPerformance() throws { + // Test performance of encoding many applications + let apps = (0..<100).map { i in + ApplicationInfo( + name: "App\(i)", + bundleIdentifier: "com.example.app\(i)", + processIdentifier: pid_t(1000 + i), + isActive: i == 0 + ) + } + + let appData = ApplicationListData(applications: apps) + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + measure { + _ = try? encoder.encode(appData) + } + } +} \ No newline at end of file diff --git a/peekaboo-cli/Tests/peekabooTests/PermissionsCheckerTests.swift b/peekaboo-cli/Tests/peekabooTests/PermissionsCheckerTests.swift new file mode 100644 index 0000000..db8ad3a --- /dev/null +++ b/peekaboo-cli/Tests/peekabooTests/PermissionsCheckerTests.swift @@ -0,0 +1,145 @@ +import XCTest +@testable import peekaboo + +final class PermissionsCheckerTests: XCTestCase { + var permissionsChecker: PermissionsChecker! + + override func setUp() { + super.setUp() + permissionsChecker = PermissionsChecker() + } + + override func tearDown() { + permissionsChecker = nil + super.tearDown() + } + + // MARK: - hasScreenRecordingPermission Tests + + func testHasScreenRecordingPermission() { + // Test screen recording permission check + let hasPermission = permissionsChecker.hasScreenRecordingPermission() + + // This test will pass or fail based on actual system permissions + // In CI/CD, this might need to be mocked + XCTAssertNotNil(hasPermission) + + // If running in a test environment without permissions, we expect false + // If running locally with permissions granted, we expect true + print("Screen recording permission status: \(hasPermission)") + } + + func testScreenRecordingPermissionConsistency() { + // Test that multiple calls return consistent results + let firstCheck = permissionsChecker.hasScreenRecordingPermission() + let secondCheck = permissionsChecker.hasScreenRecordingPermission() + + XCTAssertEqual(firstCheck, secondCheck, "Permission check should be consistent") + } + + // MARK: - hasAccessibilityPermission Tests + + func testHasAccessibilityPermission() { + // Test accessibility permission check + let hasPermission = permissionsChecker.hasAccessibilityPermission() + + // This will return the actual system state + XCTAssertNotNil(hasPermission) + + print("Accessibility permission status: \(hasPermission)") + } + + func testAccessibilityPermissionWithTrustedCheck() { + // Test the AXIsProcessTrusted check + let isTrusted = AXIsProcessTrusted() + let hasPermission = permissionsChecker.hasAccessibilityPermission() + + // These should match + XCTAssertEqual(isTrusted, hasPermission) + } + + // MARK: - checkAllPermissions Tests + + func testCheckAllPermissions() { + // Test combined permissions check + let (screenRecording, accessibility) = permissionsChecker.checkAllPermissions() + + // Both should return boolean values + XCTAssertNotNil(screenRecording) + XCTAssertNotNil(accessibility) + + // Verify individual checks match combined check + XCTAssertEqual(screenRecording, permissionsChecker.hasScreenRecordingPermission()) + XCTAssertEqual(accessibility, permissionsChecker.hasAccessibilityPermission()) + } + + // MARK: - Permission State Tests + + func testPermissionStateEncoding() throws { + // Test that permission states can be properly encoded to JSON + let serverStatus = ServerStatus( + hasScreenRecordingPermission: true, + hasAccessibilityPermission: false + ) + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + let data = try encoder.encode(serverStatus) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertNotNil(json) + XCTAssertEqual(json?["has_screen_recording_permission"] as? Bool, true) + XCTAssertEqual(json?["has_accessibility_permission"] as? Bool, false) + } + + // MARK: - Error Handling Tests + + func testPermissionDeniedError() { + // Test error creation for permission denied + let screenError = CaptureError.permissionDeniedScreenRecording + let accessError = CaptureError.permissionDeniedAccessibility + + XCTAssertEqual(screenError.description, "Screen recording permission is required") + XCTAssertEqual(accessError.description, "Accessibility permission is required") + } + + // MARK: - Performance Tests + + func testPermissionCheckPerformance() { + // Test that permission checks are fast + measure { + _ = permissionsChecker.checkAllPermissions() + } + } + + // MARK: - Mock Tests (for CI/CD) + + func testMockPermissionScenarios() { + // Test various permission scenarios for error handling + + // Scenario 1: No permissions + var status = ServerStatus( + hasScreenRecordingPermission: false, + hasAccessibilityPermission: false + ) + XCTAssertFalse(status.hasScreenRecordingPermission) + XCTAssertFalse(status.hasAccessibilityPermission) + + // Scenario 2: Only screen recording + status = ServerStatus( + hasScreenRecordingPermission: true, + hasAccessibilityPermission: false + ) + XCTAssertTrue(status.hasScreenRecordingPermission) + XCTAssertFalse(status.hasAccessibilityPermission) + + // Scenario 3: Both permissions + status = ServerStatus( + hasScreenRecordingPermission: true, + hasAccessibilityPermission: true + ) + XCTAssertTrue(status.hasScreenRecordingPermission) + XCTAssertTrue(status.hasAccessibilityPermission) + } +} \ No newline at end of file diff --git a/peekaboo-cli/Tests/peekabooTests/WindowManagerTests.swift b/peekaboo-cli/Tests/peekabooTests/WindowManagerTests.swift new file mode 100644 index 0000000..78e2368 --- /dev/null +++ b/peekaboo-cli/Tests/peekabooTests/WindowManagerTests.swift @@ -0,0 +1,174 @@ +import XCTest +@testable import peekaboo + +final class WindowManagerTests: XCTestCase { + var windowManager: WindowManager! + + override func setUp() { + super.setUp() + windowManager = WindowManager() + } + + override func tearDown() { + windowManager = nil + super.tearDown() + } + + // MARK: - getAllWindows Tests + + func testGetAllWindows() throws { + // Test getting all windows + let windows = windowManager.getAllWindows() + + // Should have at least some windows (Finder, menu bar, etc.) + XCTAssertGreaterThan(windows.count, 0) + + // Verify window properties + for window in windows { + XCTAssertNotNil(window[kCGWindowNumber]) + XCTAssertNotNil(window[kCGWindowBounds]) + } + } + + func testGetAllWindowsContainsFinder() throws { + // Finder should always have windows + let windows = windowManager.getAllWindows() + + let finderWindows = windows.filter { window in + (window[kCGWindowOwnerName] as? String) == "Finder" + } + + XCTAssertGreaterThan(finderWindows.count, 0, "Should find at least one Finder window") + } + + // MARK: - getWindowsForApp Tests + + func testGetWindowsForAppByPID() throws { + // Get Finder's PID + let apps = NSWorkspace.shared.runningApplications + guard let finder = apps.first(where: { $0.bundleIdentifier == "com.apple.finder" }) else { + XCTFail("Finder not found") + return + } + + let windows = windowManager.getWindowsForApp(pid: finder.processIdentifier, appName: nil) + + XCTAssertGreaterThan(windows.count, 0) + + // All windows should belong to Finder + for window in windows { + XCTAssertEqual(window[kCGWindowOwnerPID] as? pid_t, finder.processIdentifier) + } + } + + func testGetWindowsForAppByName() throws { + // Test filtering by app name + let allWindows = windowManager.getAllWindows() + let finderWindows = windowManager.getWindowsForApp(pid: nil, appName: "Finder") + + // Should have fewer windows when filtered + XCTAssertLessThanOrEqual(finderWindows.count, allWindows.count) + + // All returned windows should be from Finder + for window in finderWindows { + XCTAssertEqual(window[kCGWindowOwnerName] as? String, "Finder") + } + } + + func testGetWindowsForNonExistentApp() { + // Test with non-existent app + let windows = windowManager.getWindowsForApp(pid: 99999, appName: nil) + + XCTAssertEqual(windows.count, 0) + } + + // MARK: - Window Filtering Tests + + func testWindowFilteringExcludesInvisible() { + // Get all windows including invisible ones + let allWindows = windowManager.getAllWindows() + + // Check that we're not including windows that are off-screen or have zero size + for window in allWindows { + if let bounds = window[kCGWindowBounds] as? CFDictionary { + let rect = CGRect(dictionaryRepresentation: bounds) ?? .zero + + // If window is included, it should have non-zero size + if rect.width > 0 && rect.height > 0 { + XCTAssertGreaterThan(rect.width, 0) + XCTAssertGreaterThan(rect.height, 0) + } + } + } + } + + func testWindowOrdering() { + // Test that windows are ordered (typically by window level and order) + let windows = windowManager.getAllWindows() + + guard windows.count > 1 else { + XCTSkip("Need multiple windows to test ordering") + return + } + + // Windows should have window numbers + for window in windows { + XCTAssertNotNil(window[kCGWindowNumber]) + } + } + + // MARK: - Performance Tests + + func testGetAllWindowsPerformance() { + // Test performance of getting all windows + measure { + _ = windowManager.getAllWindows() + } + } + + func testGetWindowsForAppPerformance() { + // Test performance of filtered window retrieval + measure { + _ = windowManager.getWindowsForApp(pid: nil, appName: "Finder") + } + } + + // MARK: - Helper Methods Tests + + func testCreateWindowInfo() { + // Create a mock window dictionary + let mockWindow: [CFString: Any] = [ + kCGWindowNumber: 123, + kCGWindowOwnerName: "TestApp", + kCGWindowName: "Test Window", + kCGWindowOwnerPID: 456, + kCGWindowBounds: [ + "X": 100, + "Y": 200, + "Width": 800, + "Height": 600 + ] as CFDictionary, + kCGWindowIsOnscreen: true, + kCGWindowLayer: 0 + ] + + // Test window info creation + let windowInfo = WindowInfo( + windowID: 123, + owningApplication: "TestApp", + windowTitle: "Test Window", + windowIndex: 0, + bounds: WindowBounds(x: 100, y: 200, width: 800, height: 600), + isOnScreen: true, + windowLevel: 0 + ) + + XCTAssertEqual(windowInfo.windowID, 123) + XCTAssertEqual(windowInfo.owningApplication, "TestApp") + XCTAssertEqual(windowInfo.windowTitle, "Test Window") + XCTAssertEqual(windowInfo.bounds.x, 100) + XCTAssertEqual(windowInfo.bounds.y, 200) + XCTAssertEqual(windowInfo.bounds.width, 800) + XCTAssertEqual(windowInfo.bounds.height, 600) + } +} \ No newline at end of file diff --git a/scripts/prepare-release.js b/scripts/prepare-release.js index 97a6186..6628c07 100755 --- a/scripts/prepare-release.js +++ b/scripts/prepare-release.js @@ -210,6 +210,99 @@ function checkSwift() { } logSuccess('Swift tests passed'); + // Test Swift CLI commands directly + log('Testing Swift CLI commands...', colors.cyan); + + // Test help command + const helpOutput = exec('./peekaboo --help', { allowFailure: true }); + if (!helpOutput || !helpOutput.includes('USAGE:')) { + logError('Swift CLI help command failed'); + return false; + } + + // Test version command + const versionOutput = exec('./peekaboo --version', { allowFailure: true }); + if (!versionOutput) { + logError('Swift CLI version command failed'); + return false; + } + + // Test list server_status command + const serverStatusOutput = exec('./peekaboo list server_status --json-output', { allowFailure: true }); + if (!serverStatusOutput) { + logError('Swift CLI server_status command failed'); + return false; + } + + try { + const status = JSON.parse(serverStatusOutput); + if (!status.hasOwnProperty('has_screen_recording_permission') || + !status.hasOwnProperty('has_accessibility_permission')) { + logError('Server status missing required fields'); + return false; + } + } catch (e) { + logError('Swift CLI server_status JSON output is invalid'); + return false; + } + + // Test list running_applications command + const appsOutput = exec('./peekaboo list running_applications --json-output', { allowFailure: true }); + if (!appsOutput) { + logError('Swift CLI running_applications command failed'); + return false; + } + + try { + const appsData = JSON.parse(appsOutput); + if (!appsData.applications || !Array.isArray(appsData.applications)) { + logError('Applications list has invalid structure'); + return false; + } + // Should always have at least Finder running + const hasApps = appsData.applications.length > 0; + if (!hasApps) { + logError('No running applications found (expected at least Finder)'); + return false; + } + } catch (e) { + logError('Swift CLI applications JSON output is invalid'); + return false; + } + + // Test list windows command for Finder + const windowsOutput = exec('./peekaboo list windows --app Finder --json-output', { allowFailure: true }); + if (!windowsOutput) { + logError('Swift CLI windows command failed'); + return false; + } + + try { + const windowsData = JSON.parse(windowsOutput); + if (!windowsData.target_app || !windowsData.windows) { + logError('Windows list has invalid structure'); + return false; + } + } catch (e) { + logError('Swift CLI windows JSON output is invalid'); + return false; + } + + // Test error handling - non-existent app + const errorOutput = exec('./peekaboo list windows --app NonExistentApp12345 --json-output 2>&1', { allowFailure: true }); + if (errorOutput && !errorOutput.includes('error')) { + logWarning('Error handling may not be working correctly'); + } + + // Test image command help + const imageHelpOutput = exec('./peekaboo image --help', { allowFailure: true }); + if (!imageHelpOutput || !imageHelpOutput.includes('mode')) { + logError('Swift CLI image help command failed'); + return false; + } + + logSuccess('Swift CLI commands working correctly'); + return true; } @@ -247,6 +340,366 @@ function checkVersionAvailability() { return true; } +function checkChangelog() { + logStep('Changelog Entry Check'); + + const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8')); + const version = packageJson.version; + + // Read CHANGELOG.md + const changelogPath = join(projectRoot, 'CHANGELOG.md'); + if (!existsSync(changelogPath)) { + logError('CHANGELOG.md not found'); + return false; + } + + const changelog = readFileSync(changelogPath, 'utf8'); + + // Check for version entry (handle both x.x.x and x.x.x-beta.x formats) + const versionPattern = new RegExp(`^#+\\s*(?:\\[)?${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\])?`, 'm'); + if (!changelog.match(versionPattern)) { + logError(`No entry found for version ${version} in CHANGELOG.md`); + logError('Please add a changelog entry before releasing'); + return false; + } + + logSuccess(`CHANGELOG.md contains entry for version ${version}`); + return true; +} + +function checkSecurityAudit() { + logStep('Security Audit'); + + log('Running npm audit...', colors.cyan); + + const auditResult = exec('npm audit --json', { allowFailure: true }); + + if (auditResult) { + try { + const audit = JSON.parse(auditResult); + const vulnCount = audit.metadata?.vulnerabilities || {}; + const total = Object.values(vulnCount).reduce((sum, count) => sum + count, 0); + + if (total > 0) { + logWarning(`Found ${total} vulnerabilities:`); + if (vulnCount.critical > 0) logError(` Critical: ${vulnCount.critical}`); + if (vulnCount.high > 0) logError(` High: ${vulnCount.high}`); + if (vulnCount.moderate > 0) logWarning(` Moderate: ${vulnCount.moderate}`); + if (vulnCount.low > 0) log(` Low: ${vulnCount.low}`, colors.yellow); + + if (vulnCount.critical > 0 || vulnCount.high > 0) { + logError('Critical or high severity vulnerabilities found. Please fix before releasing.'); + return false; + } + + logWarning('Non-critical vulnerabilities found. Consider fixing before release.'); + } else { + logSuccess('No security vulnerabilities found'); + } + } catch (e) { + logWarning('Could not parse npm audit results'); + } + } else { + logSuccess('No security vulnerabilities found'); + } + + return true; +} + +function checkPackageSize() { + logStep('Package Size Check'); + + // Create a temporary package to get accurate size + log('Calculating package size...', colors.cyan); + const packOutput = exec('npm pack --dry-run 2>&1'); + + // Extract size information + const unpackedMatch = packOutput.match(/unpacked size: ([^\n]+)/); + + if (unpackedMatch) { + const sizeStr = unpackedMatch[1]; + + // Convert to bytes for comparison + let sizeInBytes = 0; + if (sizeStr.includes('MB')) { + sizeInBytes = parseFloat(sizeStr) * 1024 * 1024; + } else if (sizeStr.includes('kB')) { + sizeInBytes = parseFloat(sizeStr) * 1024; + } else if (sizeStr.includes('B')) { + sizeInBytes = parseFloat(sizeStr); + } + + const maxSizeInBytes = 2 * 1024 * 1024; // 2MB + + if (sizeInBytes > maxSizeInBytes) { + logWarning(`Package size (${sizeStr}) exceeds 2MB threshold`); + logWarning('Consider reviewing included files to reduce package size'); + } else { + logSuccess(`Package size (${sizeStr}) is within acceptable limits`); + } + } else { + logWarning('Could not determine package size'); + } + + return true; +} + +function checkTypeScriptDeclarations() { + logStep('TypeScript Declarations Check'); + + // Check if .d.ts files are generated + const distPath = join(projectRoot, 'dist'); + + if (!existsSync(distPath)) { + logError('dist/ directory not found. Please build the project first.'); + return false; + } + + // Look for .d.ts files + const dtsFiles = exec(`find "${distPath}" -name "*.d.ts" -type f`, { allowFailure: true }); + + if (!dtsFiles || dtsFiles.trim() === '') { + logError('No TypeScript declaration files (.d.ts) found in dist/'); + logError('Ensure TypeScript is configured to generate declarations'); + return false; + } + + const declarationFiles = dtsFiles.split('\n').filter(f => f.trim()); + log(`Found ${declarationFiles.length} TypeScript declaration files`, colors.cyan); + + // Check for main declaration file + const mainDtsPath = join(distPath, 'index.d.ts'); + if (!existsSync(mainDtsPath)) { + logError('Missing main declaration file: dist/index.d.ts'); + return false; + } + + logSuccess('TypeScript declarations are properly generated'); + return true; +} + +function checkMCPServerSmoke() { + logStep('MCP Server Smoke Test'); + + const serverPath = join(projectRoot, 'dist', 'index.js'); + + if (!existsSync(serverPath)) { + logError('Server not built. Please run build first.'); + return false; + } + + log('Testing MCP server with simple JSON-RPC request...', colors.cyan); + + try { + // Test with a simple tools/list request + const testRequest = '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'; + const result = exec(`echo '${testRequest}' | node "${serverPath}"`, { allowFailure: true }); + + if (!result) { + logError('MCP server failed to respond'); + return false; + } + + // Parse and validate response + const lines = result.split('\n').filter(line => line.trim()); + const response = lines[lines.length - 1]; // Get last line (the actual response) + + try { + const parsed = JSON.parse(response); + + if (parsed.error) { + logError(`MCP server returned error: ${parsed.error.message}`); + return false; + } + + if (!parsed.result || !parsed.result.tools) { + logError('MCP server response missing expected tools array'); + return false; + } + + const toolCount = parsed.result.tools.length; + log(`MCP server responded successfully with ${toolCount} tools`, colors.cyan); + + } catch (e) { + logError('Failed to parse MCP server response'); + logError(`Response: ${response}`); + return false; + } + + } catch (error) { + logError(`MCP server smoke test failed: ${error.message}`); + return false; + } + + logSuccess('MCP server smoke test passed'); + return true; +} + +function checkSwiftCLIIntegration() { + logStep('Swift CLI Integration Tests'); + + log('Testing Swift CLI error handling and edge cases...', colors.cyan); + + // Test 1: Invalid command + const invalidCmd = exec('./peekaboo invalid-command 2>&1', { allowFailure: true }); + if (!invalidCmd || !invalidCmd.includes('Error') && !invalidCmd.includes('USAGE')) { + logError('Swift CLI should show error for invalid command'); + return false; + } + + // Test 2: Missing required arguments + const missingArgs = exec('./peekaboo image --mode app --json-output 2>&1', { allowFailure: true }); + if (!missingArgs || (!missingArgs.includes('error') && !missingArgs.includes('Error'))) { + logError('Swift CLI should show error for missing --app with app mode'); + return false; + } + + // Test 3: Invalid window ID + const invalidWindowId = exec('./peekaboo image --mode window --window-id abc --json-output 2>&1', { allowFailure: true }); + if (!invalidWindowId || !invalidWindowId.includes('Error')) { + logError('Swift CLI should show error for invalid window ID'); + return false; + } + + // Test 4: Test all subcommands are available + const subcommands = ['list', 'image']; + for (const cmd of subcommands) { + const helpOutput = exec(`./peekaboo ${cmd} --help`, { allowFailure: true }); + if (!helpOutput || !helpOutput.includes('USAGE')) { + logError(`Swift CLI ${cmd} command help not available`); + return false; + } + } + + // Test 5: JSON output format validation + const formats = [ + { cmd: './peekaboo list server_status --json-output', required: ['has_screen_recording_permission'] }, + { cmd: './peekaboo list running_applications --json-output', required: ['applications'] } + ]; + + for (const { cmd, required } of formats) { + const output = exec(cmd, { allowFailure: true }); + if (!output) { + logError(`Command failed: ${cmd}`); + return false; + } + + try { + const data = JSON.parse(output); + for (const field of required) { + if (!(field in data)) { + logError(`Missing required field '${field}' in: ${cmd}`); + return false; + } + } + } catch (e) { + logError(`Invalid JSON from: ${cmd}`); + return false; + } + } + + // Test 6: Permission handling + const permissionTest = exec('./peekaboo list server_status --json-output', { allowFailure: true }); + if (permissionTest) { + try { + const status = JSON.parse(permissionTest); + log(`Permissions - Screen Recording: ${status.has_screen_recording_permission}, Accessibility: ${status.has_accessibility_permission}`, colors.cyan); + } catch (e) { + // Ignore, already tested above + } + } + + logSuccess('Swift CLI integration tests passed'); + return true; +} + +function checkVersionConsistency() { + logStep('Version Consistency Check'); + + const packageJsonPath = join(projectRoot, 'package.json'); + const packageLockPath = join(projectRoot, 'package-lock.json'); + + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const packageVersion = packageJson.version; + + // Check package-lock.json + if (!existsSync(packageLockPath)) { + logError('package-lock.json not found'); + return false; + } + + const packageLock = JSON.parse(readFileSync(packageLockPath, 'utf8')); + const lockVersion = packageLock.version; + + if (packageVersion !== lockVersion) { + logError(`Version mismatch: package.json has ${packageVersion}, package-lock.json has ${lockVersion}`); + logError('Run "npm install" to update package-lock.json'); + return false; + } + + // Also check that the package name matches in package-lock + if (packageLock.packages && packageLock.packages[''] && packageLock.packages[''].version !== packageVersion) { + logError(`Version mismatch in package-lock.json packages section`); + return false; + } + + logSuccess(`Version ${packageVersion} is consistent across package.json and package-lock.json`); + return true; +} + +function checkRequiredFields() { + logStep('Required Fields Validation'); + + const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8')); + + const requiredFields = { + 'name': 'Package name', + 'version': 'Package version', + 'description': 'Package description', + 'main': 'Main entry point', + 'type': 'Module type', + 'scripts': 'Scripts section', + 'repository': 'Repository information', + 'keywords': 'Keywords for npm search', + 'author': 'Author information', + 'license': 'License', + 'engines': 'Node.js engine requirements', + 'files': 'Files to include in package' + }; + + const missingFields = []; + + for (const [field, description] of Object.entries(requiredFields)) { + if (!packageJson[field]) { + missingFields.push(`${field} (${description})`); + } + } + + if (missingFields.length > 0) { + logError('Missing required fields in package.json:'); + missingFields.forEach(field => logError(` - ${field}`)); + return false; + } + + // Additional validations + if (!packageJson.repository || typeof packageJson.repository !== 'object' || !packageJson.repository.url) { + logError('Repository field must be an object with a url property'); + return false; + } + + if (!Array.isArray(packageJson.keywords) || packageJson.keywords.length === 0) { + logWarning('Keywords array is empty. Consider adding keywords for better discoverability'); + } + + if (!packageJson.engines || !packageJson.engines.node) { + logError('Missing engines.node field to specify Node.js version requirements'); + return false; + } + + logSuccess('All required fields are present in package.json'); + return true; +} + function buildAndVerifyPackage() { logStep('Build and Package Verification'); @@ -372,11 +825,19 @@ async function main() { const checks = [ checkGitStatus, + checkRequiredFields, checkDependencies, + checkSecurityAudit, checkVersionAvailability, + checkVersionConsistency, + checkChangelog, checkTypeScript, + checkTypeScriptDeclarations, checkSwift, - buildAndVerifyPackage + buildAndVerifyPackage, + checkSwiftCLIIntegration, + checkPackageSize, + checkMCPServerSmoke ]; for (const check of checks) {