From 7f4cd69798a945dca97f2d626ae162247837844f Mon Sep 17 00:00:00 2001 From: Maples7 Date: Thu, 14 May 2026 17:40:38 +0800 Subject: [PATCH] fix: prune stale simulator bindings before selection --- CHANGELOG.md | 3 + .../Services/SimulatorService.swift | 35 ++++++++-- .../Services/BuildServiceTests.swift | 10 +++ .../Services/SimulatorServiceTests.swift | 66 ++++++++++++++++++- 4 files changed, 108 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b73058..fabfb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/VibeChardCore/Services/SimulatorService.swift b/Sources/VibeChardCore/Services/SimulatorService.swift index 657a382..b2783ac 100644 --- a/Sources/VibeChardCore/Services/SimulatorService.swift +++ b/Sources/VibeChardCore/Services/SimulatorService.swift @@ -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 @@ -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 { @@ -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). diff --git a/Tests/VibeChardCoreTests/Services/BuildServiceTests.swift b/Tests/VibeChardCoreTests/Services/BuildServiceTests.swift index feb171d..fe933bc 100644 --- a/Tests/VibeChardCoreTests/Services/BuildServiceTests.swift +++ b/Tests/VibeChardCoreTests/Services/BuildServiceTests.swift @@ -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, diff --git a/Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift b/Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift index 90afd0c..6fe8efa 100644 --- a/Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift +++ b/Tests/VibeChardCoreTests/Services/SimulatorServiceTests.swift @@ -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) } @@ -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") @@ -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