Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 77 additions & 5 deletions Sources/VibeChardCore/Services/SimulatorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -460,23 +465,34 @@ 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 }
throw VibeChardError.simulatorTemplateNotFound(
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?):
Expand All @@ -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
Expand Down
141 changes: 141 additions & 0 deletions Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down