diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b0f9f..4b73058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ The English README is the source of truth; localized READMEs may lag. ## Unreleased +### Added +- Simulator base device auto-creation (#110): when `vch build --device "iPhone 17" --runtime "iOS 26.5"` is invoked and the device type + runtime are installed but no base device instance exists, vch now automatically creates it via `xcrun simctl create`. This removes the need for users to manually run `simctl create` before their first build — the device type + runtime are sufficient. The created device is not auto-deleted; the user owns its lifecycle and can clean up via `vch doctor --clean` if desired. + +### Changed +- Improved error messages when a simulator device cannot be found. The diagnostics now distinguish three failure modes: + 1. Device type not installed → `Device type 'iPhone 17' not installed` + 2. Runtime not installed → `Runtime 'iOS 26.5' not installed` + 3. No base device exists and `--runtime` not specified → suggests adding `--runtime` to trigger auto-creation + The previous generic "available: none" message is replaced with actionable guidance (#110). + ## 0.8.1 - 2026-05-14 ### Fixed diff --git a/Sources/VibeChardCore/Services/SimulatorService.swift b/Sources/VibeChardCore/Services/SimulatorService.swift index bdc6ab1..657a382 100644 --- a/Sources/VibeChardCore/Services/SimulatorService.swift +++ b/Sources/VibeChardCore/Services/SimulatorService.swift @@ -445,6 +445,11 @@ public struct SimulatorService: Sendable { /// tiebreak by UDID). Templates with `isAvailable == false` are /// filtered out. /// + /// Lazy creation (#110): if the device type is valid but no base device + /// exists, vch auto-creates it via `simctl create`. The user still owns + /// the lifecycle (can delete via `vch doctor --clean`), but doesn't need + /// to manually run `xcrun simctl create` first. + /// /// `requestedRuntime` (#11) further filters by the runtime /// identifier. Accepted forms (per platform — iOS / watchOS / /// tvOS / visionOS): @@ -460,12 +465,17 @@ public struct SimulatorService: Sendable { ) throws -> SimDevice { let all = try simctl.availableDevices() var matches = all.filter { $0.isAvailable && $0.name == name } + + // If requestedRuntime is specified, filter further. But if no matches + // are found after filtering, DON'T throw yet — we may auto-create. if let req = requestedRuntime, let target = parseRuntimeRequest(req) { - matches = matches.filter { $0.runtimeVersion == target } - guard !matches.isEmpty else { - // Surface the runtimes we DO have for this device so - // the user can copy-paste the right `--runtime` value. + let runtimeFiltered = matches.filter { $0.runtimeVersion == target } + if !runtimeFiltered.isEmpty { + matches = runtimeFiltered + } else if !matches.isEmpty { + // We have devices with this name but NOT this runtime. + // Surface the runtimes we DO have so the user can copy-paste. let available = all .filter { $0.isAvailable && $0.name == name } .compactMap { $0.runtimeVersion?.dottedLabel } @@ -473,10 +483,16 @@ public struct SimulatorService: Sendable { name: "\(name) (runtime '\(req)' — available: \(available.isEmpty ? "none" : available.joined(separator: ", ")))" ) } + // else: no devices with this name at all, matches stays empty, + // and we'll try auto-create below. } + guard !matches.isEmpty else { - throw VibeChardError.simulatorTemplateNotFound(name: name) + // No available device found. Try lazy creation (#110): + // Attempt to auto-create the base device. + return try createBaseDeviceIfNeeded(name: name, requestedRuntime: requestedRuntime) } + let sorted = matches.sorted { lhs, rhs in switch (lhs.runtimeVersion, rhs.runtimeVersion) { case let (l?, r?): @@ -495,6 +511,62 @@ public struct SimulatorService: Sendable { return pick } + /// Attempt to auto-create a base device when the device type is valid + /// but no instance exists yet (#110). Throws with diagnostic if + /// device type is not installed or runtime is invalid. + private func createBaseDeviceIfNeeded( + name: String, + requestedRuntime: String? + ) throws -> SimDevice { + // Determine the target runtime. Must be specified. + guard let req = requestedRuntime else { + throw VibeChardError.simulatorTemplateNotFound( + name: "\(name) — no base device exists and --runtime not specified. Try: --runtime 'iOS 26.5'" + ) + } + + guard let targetRuntime = parseRuntimeRequest(req) else { + throw VibeChardError.simulatorTemplateNotFound( + name: "\(name) (runtime '\(req)') — unrecognized runtime format" + ) + } + + // Attempt to create the device with the target runtime. + let newUDID: String + do { + newUDID = try simctl.create( + name: name, + deviceTypeID: name, + runtimeID: targetRuntime.runtimeIdentifier + ) + } catch let VibeChardError.externalCommandFailed(cmd, _, stderr) { + // Diagnose the failure to help the user. + let lower = stderr.lowercased() + if lower.contains("invalid device type") { + throw VibeChardError.simulatorTemplateNotFound( + name: "Device type '\(name)' not installed. Check: xcrun simctl list devicetypes" + ) + } else if lower.contains("no such runtime") || lower.contains("runtime.*not found") { + throw VibeChardError.simulatorTemplateNotFound( + name: "Runtime '\(targetRuntime.dottedLabel)' not installed. Check: xcrun simctl list runtimes" + ) + } + throw VibeChardError.externalCommandFailed(cmd: cmd, exitCode: -1, stderr: stderr) + } catch { + throw error + } + + // Successfully created. Return the new device. + return SimDevice( + udid: newUDID, + name: name, + runtime: targetRuntime.runtimeIdentifier, + runtimeVersion: targetRuntime, + isAvailable: true, + state: "Shutdown" + ) + } + /// Normalize the user's `--runtime` argument into a comparable /// `SimRuntimeVersion`. Accepts the three forms documented on /// `pickNewestTemplate` for any of the four supported platforms diff --git a/Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift b/Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift index b2ab5b7..90afd0c 100644 --- a/Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift +++ b/Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift @@ -1023,6 +1023,147 @@ final class SimulatorServiceTests: XCTestCase { ) XCTAssertEqual(bound?.cloneUDID, "C-26") } + + // MARK: - auto-create base device (#110) + + func testPickNewestTemplateAutoCreatesBaseDeviceWhenMissing() throws { + // No device with name "iPhone 17" exists, but the user passes + // --device "iPhone 17" --runtime "iOS 26.5". pickNewestTemplate + // should auto-create a base device for that combination. + let (service, _, simctl) = makeService( + seedingTask: "alpha", + seedingState: emptyState("alpha"), + devices: [], // No existing devices + cloneReturnsUDID: "NEW-BASE-UDID" + ) + simctl.createReturnsUDID = "NEW-BASE-UDID" + + let pick = try service.pickNewestTemplate( + name: "iPhone 17", + requestedRuntime: "iOS 26.5" + ) + XCTAssertEqual(pick.udid, "NEW-BASE-UDID") + XCTAssertEqual(pick.name, "iPhone 17") + XCTAssertEqual(pick.isAvailable, true) + XCTAssertEqual(pick.state, "Shutdown") + XCTAssertTrue(pick.runtime.contains("iOS-26-5"), "runtime identifier should match requested runtime") + XCTAssertEqual(pick.runtimeVersion?.platform, .iOS, "platform should be iOS") + XCTAssertEqual(pick.runtimeVersion?.major, 26, "major version should be 26") + XCTAssertEqual(pick.runtimeVersion?.minor, 5, "minor version should be 5") + XCTAssertEqual(simctl.createCalls.count, 1) + let call = simctl.createCalls[0] + XCTAssertEqual(call.name, "iPhone 17") + XCTAssertEqual(call.deviceTypeID, "iPhone 17") + XCTAssertTrue(call.runtimeID.contains("iOS-26-5")) + } + + func testPickNewestTemplateThrowsWhenAutoCreateFailsNoRuntime() throws { + // User provides --device but not --runtime. Auto-create + // requires a runtime, so it should fail with a helpful message. + let (service, _, _) = makeService( + seedingTask: "alpha", + seedingState: emptyState("alpha"), + devices: [] + ) + XCTAssertThrowsError(try service.pickNewestTemplate( + name: "iPhone 17", + requestedRuntime: nil + )) { err in + guard case let VibeChardError.simulatorTemplateNotFound(name) = err else { + return XCTFail("expected simulatorTemplateNotFound, got \(err)") + } + XCTAssertTrue(name.contains("no base device"), "error should mention 'no base device', got: \(name)") + XCTAssertTrue(name.contains("--runtime"), "error should mention '--runtime', got: \(name)") + } + } + + func testPickNewestTemplateThrowsWhenAutoCreateFailsInvalidRuntime() throws { + // User specifies an invalid runtime format. + let (service, _, _) = makeService( + seedingTask: "alpha", + seedingState: emptyState("alpha"), + devices: [] + ) + XCTAssertThrowsError(try service.pickNewestTemplate( + name: "iPhone 17", + requestedRuntime: "not-a-runtime" + )) { err in + guard case let VibeChardError.simulatorTemplateNotFound(name) = err else { + return XCTFail("expected simulatorTemplateNotFound, got \(err)") + } + XCTAssertTrue(name.contains("unrecognized runtime"), "error should mention runtime format, got: \(name)") + } + } + + func testPickNewestTemplateThrowsWhenAutoCreateFailsDeviceTypeNotInstalled() throws { + // User specifies a device type that doesn't exist. + let (service, _, simctl) = makeService( + seedingTask: "alpha", + seedingState: emptyState("alpha"), + devices: [] + ) + simctl.createThrows = .externalCommandFailed( + cmd: "xcrun simctl create", + exitCode: 1, + stderr: "Invalid device type: NonexistentDevice" + ) + XCTAssertThrowsError(try service.pickNewestTemplate( + name: "NonexistentDevice", + requestedRuntime: "iOS 26.5" + )) { err in + guard case let VibeChardError.simulatorTemplateNotFound(name) = err else { + return XCTFail("expected simulatorTemplateNotFound, got \(err)") + } + XCTAssertTrue(name.contains("not installed"), "error should mention device type not installed, got: \(name)") + } + } + + func testPickNewestTemplateThrowsWhenAutoCreateFailsRuntimeNotInstalled() throws { + // Runtime exists but user specified an uninstalled version. + let (service, _, simctl) = makeService( + seedingTask: "alpha", + seedingState: emptyState("alpha"), + devices: [] + ) + simctl.createThrows = .externalCommandFailed( + cmd: "xcrun simctl create", + exitCode: 1, + stderr: "No such runtime: iOS 99.0" + ) + XCTAssertThrowsError(try service.pickNewestTemplate( + name: "iPhone 17", + requestedRuntime: "iOS 99.0" + )) { err in + guard case let VibeChardError.simulatorTemplateNotFound(name) = err else { + return XCTFail("expected simulatorTemplateNotFound, got \(err)") + } + XCTAssertTrue(name.contains("not installed"), "error should mention runtime not installed, got: \(name)") + } + } + + func testPickNewestTemplatePrefersExistingDeviceOverCreating() throws { + // When a device with the requested name already exists, reuse it + // instead of auto-creating. + let (service, _, simctl) = makeService( + seedingTask: "alpha", + seedingState: emptyState("alpha"), + devices: [ + device("U-EXIST", "iPhone 17", + "com.apple.CoreSimulator.SimRuntime.iOS-26-5", + .init(platform: .iOS, major: 26, minor: 5)), + ], + cloneReturnsUDID: "SHOULD-NOT-CREATE" + ) + simctl.createReturnsUDID = "SHOULD-NOT-CREATE" + + let pick = try service.pickNewestTemplate( + name: "iPhone 17", + requestedRuntime: "iOS 26.5" + ) + XCTAssertEqual(pick.udid, "U-EXIST") + XCTAssertEqual(simctl.createCalls.count, 0, "should not create when device exists") + } + } // MARK: - test double