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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ The English README is the source of truth; localized READMEs may lag.
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).

### Fixed
- Pruned stale simulator bindings before simulator selection, so a clone deleted outside vch no longer causes `vch build`, `vch test`, or `vch sim` commands to report false multi-binding ambiguity (#111).

## 0.8.1 - 2026-05-14

### Fixed
Expand Down
35 changes: 30 additions & 5 deletions Sources/VibeChardCore/Services/SimulatorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ public struct SimulatorService: Sendable {
let data = try fs.readFile(at: statePath)
var state = try TaskState.parse(data)

let existing = state.allSimulators
let existing = try pruneStaleSimulatorBindings(
statePath: statePath,
state: &state
)

// No --device: pure-reuse path. 0 → nil, 1 → reuse, ≥2 → ambiguous.
// When the caller knows the scheme's simulator platform, scope
Expand Down Expand Up @@ -347,9 +350,12 @@ public struct SimulatorService: Sendable {
}
}

/// Read every simulator binding persisted for `task` (#99).
/// Read every live simulator binding persisted for `task` (#99).
/// Bindings whose clone UDID has disappeared from `simctl list`
/// are pruned from state before returning (#111).
/// Always returns a (possibly empty) list — never throws on
/// ambiguity. Throws when `state.json` is missing/corrupt.
/// ambiguity. Throws when `state.json` is missing/corrupt, or
/// when simctl cannot be queried for a non-empty binding list.
public func lookupAllBindings(task: TaskName) throws -> [TaskState.SimulatorRecord] {
let statePath = workspace.statePath(for: task)
guard fs.fileExists(at: statePath) else {
Expand All @@ -359,8 +365,27 @@ public struct SimulatorService: Sendable {
)
}
let data = try fs.readFile(at: statePath)
let state = try TaskState.parse(data)
return state.allSimulators
var state = try TaskState.parse(data)
return try pruneStaleSimulatorBindings(
statePath: statePath,
state: &state
)
}

private func pruneStaleSimulatorBindings(
statePath: String,
state: inout TaskState
) throws -> [TaskState.SimulatorRecord] {
let existing = state.allSimulators
guard !existing.isEmpty else { return [] }

let liveUDIDs = Set(try simctl.allDevices().map(\.udid))
let live = existing.filter { liveUDIDs.contains($0.cloneUDID) }
guard live.count != existing.count else { return existing }

state.setSimulators(live)
try fs.writeFileAtomic(state.jsonData(), to: statePath)
return live
}

/// Resolve which binding a CLI subcommand should operate on (#99).
Expand Down
10 changes: 10 additions & 0 deletions Tests/VibeChardCoreTests/Services/BuildServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,16 @@ final class BuildServiceTests: XCTestCase {
]
"""#.utf8))
let simctl = FakeSimctl()
simctl.allDevicesOverride = [
SimDevice(udid: "WATCH-CLONE", name: "Apple Watch Series 10-vch-alpha",
runtime: "com.apple.CoreSimulator.SimRuntime.watchOS-11-0",
runtimeVersion: .init(platform: .watchOS, major: 11, minor: 0),
isAvailable: true),
SimDevice(udid: "IOS-CLONE", name: "iPhone 16-vch-alpha",
runtime: "com.apple.CoreSimulator.SimRuntime.iOS-18-2",
runtimeVersion: .init(major: 18, minor: 2),
isAvailable: true),
]
let sim = SimulatorService(workspace: workspace, simctl: simctl, fs: fs)
let service = BuildService(
workspace: workspace,
Expand Down
66 changes: 65 additions & 1 deletion Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ final class SimulatorServiceTests: XCTestCase {
}
let simctl = FakeSimctl()
simctl.devices = devices
if let state = seedingState, !state.allSimulators.isEmpty {
simctl.allDevicesOverride = devices + state.allSimulators.map { record in
SimDevice(
udid: record.cloneUDID,
name: record.name,
runtime: record.runtimeIdentifier ?? "com.apple.CoreSimulator.SimRuntime.iOS-18-0",
runtimeVersion: record.runtimeVersion,
isAvailable: true,
state: "Shutdown"
)
}
}
simctl.cloneReturnsUDID = cloneReturnsUDID
return (SimulatorService(workspace: workspace, simctl: simctl, fs: fs), fs, simctl)
}
Expand Down Expand Up @@ -409,7 +421,14 @@ final class SimulatorServiceTests: XCTestCase {
)
fs.seedFile(workspace.statePath(for: task),
data: try state.jsonData())
let service = SimulatorService(workspace: workspace, simctl: FakeSimctl(), fs: fs)
let simctl = FakeSimctl()
simctl.allDevicesOverride = [
SimDevice(udid: "C-9", name: "iPhone 16 · vch[codex-task]",
runtime: "com.apple.CoreSimulator.SimRuntime.iOS-26-4",
runtimeVersion: .init(major: 26, minor: 4),
isAvailable: true),
]
let service = SimulatorService(workspace: workspace, simctl: simctl, fs: fs)

let bound = try service.lookupBound(task: task)
XCTAssertEqual(bound?.cloneUDID, "C-9")
Expand Down Expand Up @@ -876,6 +895,51 @@ final class SimulatorServiceTests: XCTestCase {
XCTAssertEqual(simctl.cloneCalls.count, 0)
}

func testEnsureClonePrunesStaleBindingBeforePlatformAmbiguity() throws {
// #111: if one of the stored watchOS bindings was deleted
// directly with simctl, it must not keep participating in
// the build/test ambiguity check. The remaining live binding
// should be reused and state.json should be compacted.
var seed = emptyState("alpha")
seed.setSimulators([
TaskState.SimulatorRecord(
cloneUDID: "WATCH-LIVE", sourceUDID: "WATCH-TPL-1",
name: "BeanLedger Template Watch S11 26.5-vch-alpha",
templateName: "BeanLedger Template Watch S11 26.5",
runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.watchOS-26-5"
),
TaskState.SimulatorRecord(
cloneUDID: "WATCH-GONE", sourceUDID: "WATCH-TPL-2",
name: "Apple Watch Series 11 (46mm)-vch-alpha",
templateName: "Apple Watch Series 11 (46mm)",
runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.watchOS-26-5"
),
])
let (service, fs, simctl) = makeService(seedingTask: "alpha", seedingState: seed)
simctl.allDevicesOverride = [
device("WATCH-LIVE", "BeanLedger Template Watch S11 26.5-vch-alpha",
"com.apple.CoreSimulator.SimRuntime.watchOS-26-5",
.init(platform: .watchOS, major: 26, minor: 5)),
]

let resolved = try service.ensureClone(
task: try TaskName("alpha"),
requestedDevice: nil,
requestedPlatform: .watchOS
)

XCTAssertEqual(resolved?.udid, "WATCH-LIVE")
XCTAssertFalse(resolved?.createdNow ?? true)
XCTAssertEqual(simctl.cloneCalls.count, 0)

let workspace = Workspace(mainWorktreePath: mainRepo)
let after = try TaskState.parse(
try fs.readFile(at: workspace.statePath(for: try TaskName("alpha")))
)
XCTAssertEqual(after.allSimulators.map(\.cloneUDID), ["WATCH-LIVE"])
XCTAssertEqual(after.simulator?.cloneUDID, "WATCH-LIVE")
}

func testEnsureCloneRejectsSingleBindingFromWrongPlatform() throws {
// Regression for #102: after a task has only a watchOS binding,
// a later iOS scheme with no --device must not silently reuse
Expand Down