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
This commit is contained in:
Peter Steinberger 2025-05-25 19:12:21 +02:00
parent 1ff703b185
commit f4a41f8355
7 changed files with 1380 additions and 25 deletions

View file

@ -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:**

View file

@ -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")
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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) {