diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2ee08..05e3dd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,62 @@ jobs: -configuration Debug -destination 'platform=macOS' \ -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \ test-without-building + - name: Build the inventory fixture host + run: | + xcrun swiftc -swift-version 6 -warnings-as-errors -parse-as-library \ + -I "$RUNNER_TEMP/dd/Build/Products/Debug" \ + "$RUNNER_TEMP/dd/Build/Products/Debug/ToolkitCore.o" \ + scripts/SimulatorInventoryFixtures.swift -o "$RUNNER_TEMP/inventory-fixtures" + - name: Prepare two owned inventory simulators + id: prepare_inventory + run: | + "$RUNNER_TEMP/inventory-fixtures" prepare "$RUNNER_TEMP/inventory-fixtures-data" + python3 - "$RUNNER_TEMP/inventory-fixtures-data/manifest.json" <<'PY' + import json + import os + import sys + import uuid + from pathlib import Path + + manifest = json.loads(Path(sys.argv[1]).read_text()) + if not isinstance(manifest, dict): + raise SystemExit("Inventory fixture host did not produce an object manifest.") + for key in ("first", "second", "owner"): + value = manifest.get(key) + if not isinstance(value, str) or str(uuid.UUID(value)).upper() != value: + raise SystemExit(f"Inventory fixture manifest has no canonical {key} UUID.") + if manifest["first"] == manifest["second"]: + raise SystemExit("Inventory fixtures must use distinct simulators.") + with open(os.environ["GITHUB_ENV"], "a") as output: + output.write("TEST_RUNNER_IDT_INVENTORY_MANIFEST=" + json.dumps({key: manifest[key] for key in ("first", "second", "owner")}, separators=(",", ":")) + "\n") + PY + - name: Two-owned-simulator inventory UI acceptance + env: + TEST_RUNNER_IDT_INVENTORY_UI_TESTS: "1" + run: | + xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Debug -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/InventoryUITests.xcresult" \ + -only-testing:iOSDeveloperToolkitUITests/SimulatorInventoryUITests test-without-building + xcrun xcresulttool get test-results summary --path "$RUNNER_TEMP/InventoryUITests.xcresult" \ + > "$RUNNER_TEMP/InventoryUITests-summary.json" + python3 - "$RUNNER_TEMP/InventoryUITests-summary.json" <<'PY' + import json + import sys + from pathlib import Path + + summary = json.loads(Path(sys.argv[1]).read_text()) + expected = {"totalTestCount": 1, "passedTests": 1, "failedTests": 0, "skippedTests": 0} + if not isinstance(summary, dict) or any(type(summary.get(key)) is not int for key in expected): + raise SystemExit("Inventory acceptance did not produce valid execution counts.") + observed = {key: summary[key] for key in expected} + if observed != expected: + raise SystemExit(f"Inventory acceptance must execute and pass exactly one test: {observed}") + print(f"Two-owned-simulator native acceptance executed: {observed}") + PY + - name: Verify cleanup of both owned inventory simulators + if: ${{ always() && (steps.prepare_inventory.outcome == 'success' || steps.prepare_inventory.outcome == 'failure' || steps.prepare_inventory.outcome == 'cancelled') }} + run: '"$RUNNER_TEMP/inventory-fixtures" cleanup "$RUNNER_TEMP/inventory-fixtures-data"' - name: Render every page at the default and minimum window sizes run: | scripts/check-layout.sh \ @@ -98,6 +154,8 @@ jobs: name: ui-test-results path: | ${{ runner.temp }}/UITests.xcresult + ${{ runner.temp }}/InventoryUITests.xcresult + ${{ runner.temp }}/InventoryUITests-summary.json ${{ runner.temp }}/layout if-no-files-found: ignore diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift new file mode 100644 index 0000000..579af5e --- /dev/null +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -0,0 +1,259 @@ +import Foundation +import XCTest + +/// Native acceptance on two task-owned simulators. The explicit opt-in keeps ordinary local +/// UI smoke tests from creating or changing simulators; CI enables it in a separate step. +@MainActor +final class SimulatorInventoryUITests: XCTestCase { + private var app: XCUIApplication? + private let aOnly = "io.hideouts.idt.inventory.a" + private let bOnly = "io.hideouts.idt.inventory.b" + private let shared = "io.hideouts.idt.inventory.shared" + + override func setUp() async throws { + continueAfterFailure = false + try XCTSkipUnless(ProcessInfo.processInfo.environment["IDT_INVENTORY_UI_TESTS"] == "1", + "Requires the explicit two-owned-simulator native acceptance opt-in.") + } + + override func tearDown() async throws { + app?.terminate() + } + + func testInventoryOwnershipAcrossTwoSimulatorsAndDeliveredLateResult() throws { + let manifest = try XCTUnwrap(ProcessInfo.processInfo.environment["IDT_INVENTORY_MANIFEST"], + "CI must supply the two owned simulator fixtures") + guard manifest.utf8.count <= 4096 else { + throw InventoryFixtureFailure(message: "The inventory fixture manifest exceeds 4 KiB.") + } + let fixtures = try JSONDecoder().decode(InventoryFixtures.self, from: Data(manifest.utf8)) + guard let first = UUID(uuidString: fixtures.first), let second = UUID(uuidString: fixtures.second), + UUID(uuidString: fixtures.owner)?.uuidString == fixtures.owner, first != second, + first.uuidString == fixtures.first, second.uuidString == fixtures.second else { + throw InventoryFixtureFailure(message: "The inventory fixture manifest requires two distinct canonical UUIDs and an owner UUID.") + } + let a = fixtures.first + let b = fixtures.second + + let application = XCUIApplication() + app = application + application.launchArguments = ["-ui-testing", "YES", "-demo-mode", "NO", + "-inventory-acceptance-a", a, "-inventory-acceptance-b", b, + "-inventory-acceptance-owner", fixtures.owner, + "-workspace", "apps", "-ApplePersistenceIgnoreState", "YES"] + application.launchEnvironment["IDT_RESTORE_HELPERS"] = "" + application.launch() + application.activate() + XCTAssertTrue(application.wait(for: .runningForeground, timeout: 10)) + XCTAssertFalse(element("demo-banner").exists) + + // The discovery command has a 60-second deadline plus process-exit cleanup. + // Observe its completion with the menu closed, then require a healthy result; + // an open native menu can retain the initial, not-yet-checked item snapshot. + let discovery = element("connection-summary") + wait(discovery, predicate: "exists == true AND NOT (label CONTAINS 'Simulators: Not checked yet')", timeout: 75) + XCTAssertNotNil(discovery.label.range(of: "Simulators: [1-9][0-9]* devices", options: .regularExpression), + "Initial simulator discovery must succeed before opening the target menu: \(discovery.label)") + + selectSimulator(a) + loadInventory(containing: aOnly) + try selectApp(aOnly) + wait(element("apps-launch"), predicate: "exists == true AND enabled == true", timeout: 10) + + selectSimulator(b) + wait(element("apps-launch"), predicate: "exists == false", timeout: 10) + XCTAssertFalse(element("apps-remove").exists, "Changing target must clear row selection") + wait(element("apps-stale-inventory"), predicate: "exists == true", timeout: 10) + try selectApp(aOnly) + wait(element("apps-launch"), predicate: "exists == true AND enabled == false", timeout: 10) + XCTAssertFalse(element("apps-remove").isEnabled) + assertStaleContextActionsAreDisabled(row: element("installed-app-\(aOnly)")) + XCTAssertFalse(element("running-operations").exists, "Blocked controls must start no operation") + + loadInventory(containing: bOnly) + XCTAssertFalse(element("installed-app-\(aOnly)").exists) + XCTAssertFalse(element("apps-stale-inventory").exists) + try selectApp(shared) + wait(element("apps-remove"), predicate: "exists == true AND enabled == true", timeout: 10) + element("apps-remove").click() + confirmRemoval(on: b) + wait(element("installed-app-\(shared)"), predicate: "exists == false", timeout: 120) + wait(element("running-operations"), predicate: "exists == false", timeout: 120) + // Verify isolation through the production app's real inventories, without spawning + // tools from the sandboxed UI-test runner. CI owns fixture provisioning and cleanup. + selectSimulator(a) + loadInventory(containing: aOnly) + XCTAssertTrue(element("installed-app-\(shared)").exists, "A must retain the shared fixture") + selectSimulator(b) + loadInventory(containing: bOnly) + XCTAssertFalse(element("installed-app-\(shared)").exists, "The confirmed removal must affect B") + + // The runner first captures a genuine successful A response, then holds it past + // cancellation. Releasing it after B loads proves rejection of an actually delivered + // obsolete result, rather than merely proving that cancellation prevented delivery. + selectSimulator(a) + loadInventory(containing: aOnly) + element("inventory-gate-arm").click() + wait(element("inventory-gate-state"), predicate: "value == 'armed'", timeout: 10) + element("load-apps").click() + wait(element("inventory-gate-state"), predicate: "value == 'held'", timeout: 120) + selectSimulator(b) + loadInventory(containing: bOnly) + try selectApp(bOnly) + wait(element("apps-launch"), predicate: "exists == true AND enabled == true", timeout: 10) + let currentSource = try XCTUnwrap(element("apps-inventory-source").value as? String) + XCTAssertFalse(currentSource.isEmpty, "The inventory provenance must expose a readable native value") + element("inventory-gate-release").click() + wait(element("inventory-gate-state"), predicate: "value == 'delivered'", timeout: 10) + wait(element("running-operations"), predicate: "exists == false", timeout: 30) + XCTAssertEqual(application.sheets.count, 0, "A late reply must parse successfully without presenting an error") + XCTAssertEqual(application.alerts.count, 0) + XCTAssertTrue(element("installed-app-\(bOnly)").exists) + XCTAssertFalse(element("installed-app-\(aOnly)").exists) + XCTAssertFalse(element("apps-stale-inventory").exists) + XCTAssertEqual(element("apps-inventory-source").value as? String, currentSource) + XCTAssertTrue(element("apps-launch").isEnabled, "Late A must preserve B's selected valid row") + + // A modal confirmation cannot assume that the target picker remains reachable. + // Real loss of owned A exercises the observed target-change dismissal path instead. + selectSimulator(a) + loadInventory(containing: aOnly) + try selectApp(shared) + element("apps-remove").click() + wait(element("confirm-button"), predicate: "exists == true", timeout: 10) + XCTAssertEqual(application.sheets.count, 1) + XCTAssertFalse(element("confirm-button").isEnabled) + let deleteOwnedA = element("inventory-delete-owned-a") + wait(deleteOwnedA, predicate: "exists == true AND enabled == true", timeout: 10) + deleteOwnedA.click() + wait(element("confirm-button"), predicate: "exists == false", timeout: 180) + selectSimulator(b) + loadInventory(containing: bOnly) + wait(element("running-operations"), predicate: "exists == false", timeout: 120) + XCTAssertEqual(application.sheets.count, 0, "Owned target loss must complete without an error sheet") + XCTAssertEqual(application.alerts.count, 0) + XCTAssertFalse(element("installed-app-\(shared)").exists) + print("[inventory acceptance] stale controls, B-only removal, delivered late-A rejection, and target-loss confirmation dismissal passed") + } + + private func element(_ identifier: String) -> XCUIElement { + guard let app else { preconditionFailure("The acceptance app has not launched") } + return app.descendants(matching: .any)[identifier] + } + + private func wait(_ element: XCUIElement, predicate: String, timeout: TimeInterval) { + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(format: predicate), object: element) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + if result != .completed { + let hierarchy = app.map { String($0.debugDescription.prefix(64_000)) } ?? "The acceptance app has not launched." + let attachment = XCTAttachment(string: hierarchy) + attachment.name = "inventory-accessibility-failure" + attachment.lifetime = .keepAlways + add(attachment) + } + XCTAssertEqual(result, .completed, + "The native accessibility condition did not settle: \(predicate). Inspect the attached hierarchy and preceding element query.") + } + + private func selectSimulator(_ identifier: String) { + let picker = element("target-picker") + wait(picker, predicate: "exists == true AND hittable == true", timeout: 30) + picker.click() + let target = element("target-simulator-\(identifier)") + wait(target, predicate: "exists == true AND hittable == true", timeout: 30) + target.click() + wait(target, predicate: "exists == false", timeout: 10) + } + + private func selectApp(_ bundle: String) throws { + let table = element("installed-apps-table") + let rows = table.outlineRows.containing(.any, identifier: "installed-app-\(bundle)") + XCTAssertEqual(rows.count, 1, "The stable app identifier must resolve to one native outline row") + let row = rows.element + wait(row, predicate: "exists == true", timeout: 10) + // Native outline containers can be unhittable while their identified cells are reachable. + // Require reachability on the actual click target and selection on its unique parent row. + let names = table.staticTexts.matching(identifier: "installed-app-\(bundle)") + XCTAssertEqual(names.count, 1, "The click target must be one identified name cell in this table") + let name = names.element + wait(name, predicate: "exists == true AND hittable == true", timeout: 10) + try attachSelectionInput(phase: "readiness check", row: row) + wait(element("inventory-selection-input"), predicate: "value CONTAINS 'window-ready: true'", timeout: 10) + try attachSelectionInput(phase: "before click", row: row) + let window = try XCTUnwrap(app).windows["main-AppWindow-1"] + let frame = name.frame + XCTAssertGreaterThan(frame.width, 0) + XCTAssertGreaterThan(frame.height, 0) + XCTAssertTrue(window.frame.contains(CGPoint(x: frame.midX, y: frame.midY)), + "The identified cell's click center must be inside the main window") + // A native outline row's computed hittable point can lie beyond its cells. + // Send one real click to the center of the cell identified by this bundle ID. + name.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() + try attachSelectionInput(phase: "after click", row: row) + wait(row, predicate: "selected == true", timeout: 10) + } + + private func attachSelectionInput(phase: String, row: XCUIElement) throws { + let input = element("inventory-selection-input") + wait(input, predicate: "exists == true", timeout: 10) + let trace = try XCTUnwrap(input.value as? String, "Selection input diagnostic must expose a native value") + XCTAssertFalse(trace.isEmpty) + let attachment = XCTAttachment(string: "\(phase): selected=\(row.isSelected); \(trace)") + attachment.name = "inventory-selection-\(phase)" + attachment.lifetime = .keepAlways + add(attachment) + let application = try XCTUnwrap(app) + let window = application.windows["main-AppWindow-1"] + XCTAssertTrue(window.exists, "The identified main window must exist for input evidence") + let screenshot = XCTAttachment(screenshot: window.screenshot()) + screenshot.name = "inventory-window-\(phase)" + screenshot.lifetime = .keepAlways + add(screenshot) + } + + private func loadInventory(containing bundle: String) { + let load = element("load-apps") + wait(load, predicate: "exists == true AND enabled == true", timeout: 10) + load.click() + wait(element("installed-app-\(bundle)"), predicate: "exists == true AND hittable == true", timeout: 120) + wait(load, predicate: "enabled == true", timeout: 120) + XCTAssertFalse(element("apps-stale-inventory").exists) + } + + private func assertStaleContextActionsAreDisabled(row: XCUIElement) { + row.rightClick() + let menu = element("installed-apps-table").menus.element + wait(menu, predicate: "exists == true", timeout: 10) + // As in the Demo smoke test, SwiftUI omits identifiers on disabled native menu items. + // Assert the complete action order inside the table identified above; no text clicks. + let actions = menu.menuItems.matching(NSPredicate(format: "title != ''")) + XCTAssertEqual(actions.count, 3) + XCTAssertTrue(actions.element(boundBy: 0).isEnabled) + XCTAssertFalse(actions.element(boundBy: 1).isEnabled) + XCTAssertFalse(actions.element(boundBy: 2).isEnabled) + app?.typeKey(.escape, modifierFlags: []) + } + + private func confirmRemoval(on identifier: String) { + wait(element("confirm-button"), predicate: "exists == true", timeout: 10) + XCTAssertEqual(app?.sheets.count, 1) + let field = element("confirmation-field") + wait(field, predicate: "exists == true AND hittable == true", timeout: 10) + field.click() + field.typeText("IRREVERSIBLE \(identifier.replacingOccurrences(of: "-", with: "").suffix(6))") + element("confirmation-backup-acknowledgement").click() + wait(element("confirm-button"), predicate: "enabled == true", timeout: 10) + element("confirm-button").click() + wait(element("confirm-button"), predicate: "exists == false", timeout: 10) + } + + private struct InventoryFixtures: Decodable { + let first: String + let second: String + let owner: String + } + + private struct InventoryFixtureFailure: Error { + let message: String + } +} diff --git a/App/UITests/SmokeUITests.swift b/App/UITests/SmokeUITests.swift index 89d406e..bf65353 100644 --- a/App/UITests/SmokeUITests.swift +++ b/App/UITests/SmokeUITests.swift @@ -212,6 +212,43 @@ final class SmokeUITests: XCTestCase { attachScreenshot("action-detail") } + func testDemoAppInventoryDisablesFooterAndContextActions() throws { + openWorkspace("apps") + let load = app.buttons["load-apps"] + XCTAssertTrue(load.waitForExistence(timeout: 5)) + load.click() + let row = app.descendants(matching: .any)["installed-app-com.example.sample"] + XCTAssertTrue(row.waitForExistence(timeout: 5)) + XCTAssertTrue(row.isHittable, "The demo inventory row must be visible before selection") + row.click() + + let launch = app.buttons["apps-launch"] + let remove = app.buttons["apps-remove"] + XCTAssertTrue(launch.waitForExistence(timeout: 5)) + XCTAssertTrue(remove.waitForExistence(timeout: 5)) + XCTAssertFalse(launch.isEnabled, "Demo app inventory must not authorize Launch") + XCTAssertFalse(remove.isEnabled, "Demo app inventory must not authorize Remove") + + let table = app.descendants(matching: .any)["installed-apps-table"] + XCTAssertEqual(table.outlineRows.count, 1, "The demo inventory must expose exactly one visible row") + let menuRow = table.outlineRows.element(boundBy: 0) + XCTAssertTrue(menuRow.isHittable, "The native inventory row must be reachable before opening its menu") + row.rightClick() + // SwiftUI's native menu omits identifiers on disabled items. Anchor the observed + // menu hierarchy to this table's stable ID and verify its complete action order. + let menu = table.menus.element + XCTAssertTrue(menu.waitForExistence(timeout: 5)) + let actions = menu.menuItems.matching(NSPredicate(format: "title != ''")) + XCTAssertEqual(actions.count, 3, "The app inventory menu must contain Copy, Launch and Remove") + guard actions.count == 3 else { return } + XCTAssertTrue(actions.element(boundBy: 0).isEnabled, "Copying a demo bundle ID must remain available") + XCTAssertFalse(actions.element(boundBy: 1).isEnabled, "Demo inventory context menus must not authorize Launch") + XCTAssertFalse(actions.element(boundBy: 2).isEnabled, "Demo inventory context menus must not authorize Remove") + app.typeKey(.escape, modifierFlags: []) + XCTAssertFalse(app.buttons["running-operations"].exists, "Inspecting blocked app actions must not start an operation") + attachScreenshot("demo-app-actions-blocked") + } + func testCommandCategoriesExposeExistingActionsWithoutExecuting() throws { openWorkspace("actions") let categories: [(key: String, action: String)] = [ diff --git a/App/iOSDeveloperToolkit/Components/Components.swift b/App/iOSDeveloperToolkit/Components/Components.swift index 0853fa6..d6abf75 100644 --- a/App/iOSDeveloperToolkit/Components/Components.swift +++ b/App/iOSDeveloperToolkit/Components/Components.swift @@ -256,6 +256,7 @@ struct ConfirmationSheet: View { } if requirement.requiresBackupAcknowledgement { Toggle("I have a current backup and understand this cannot be undone", isOn: $backupAcknowledged) + .accessibilityIdentifier("confirmation-backup-acknowledgement") } if let phrase = requirement.phrase { VStack(alignment: .leading, spacing: 6) { @@ -272,6 +273,7 @@ struct ConfirmationSheet: View { Spacer() Button("Cancel", role: .cancel) { dismiss() } .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("confirmation-cancel") Button(requirement.risk == .readOnly ? "Run" : "Continue") { dismiss() onConfirm() diff --git a/App/iOSDeveloperToolkit/Model/AppModel.swift b/App/iOSDeveloperToolkit/Model/AppModel.swift index 5cfacf9..361671a 100644 --- a/App/iOSDeveloperToolkit/Model/AppModel.swift +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -65,7 +65,10 @@ final class RunningOperation: Identifiable { final class AppModel { // MARK: Services - let runner: OwnedCommandRunning = ProcessCommandRunner() + let runner: OwnedCommandRunning +#if DEBUG + let inventoryAcceptance: InventoryAcceptance? +#endif let journal = OperationJournal() let discovery: DeviceDiscovery /// A fresh executor carries the current developer-image folders. @@ -76,7 +79,10 @@ final class AppModel { var snapshot = DiscoverySnapshot() var selectedDeviceID: String? { - didSet { UserDefaults.standard.set(selectedDeviceID, forKey: "selectedDeviceID") } + didSet { + UserDefaults.standard.set(selectedDeviceID, forKey: "selectedDeviceID") + if oldValue != selectedDeviceID { apps.selectionChanged() } + } } var workspace: Workspace = .overview /// The Actions page's category filter and selection (set by profiles and the command palette). @@ -130,11 +136,41 @@ final class AppModel { } let uiTesting = flag("-ui-testing") ?? false let forceDemo = flag("-demo-mode") ?? false - demoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode")) + let isDemoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode")) + demoMode = isDemoMode selectedDeviceID = forceDemo ? DemoMode.device.id : UserDefaults.standard.string(forKey: "selectedDeviceID") - // UI tests run without touching real discovery sources. - let configuration: DeviceDiscovery.Configuration = uiTesting - ? .init(usbmux: USBMuxClient(socketPath: "/nonexistent/ui-testing-usbmuxd"), coreDevice: nil, simulators: nil, enrichWithLockdown: false) + let acceptanceRequested = arguments.contains("-inventory-acceptance-a") || arguments.contains("-inventory-acceptance-b") || arguments.contains("-inventory-acceptance-owner") + let acceptanceError: ToolkitError? +#if DEBUG + do { + if let targets = try InventoryAcceptanceTargets.parse(arguments: arguments, uiTesting: uiTesting, demoMode: isDemoMode) { + let base = ProcessCommandRunner() + let acceptance = InventoryAcceptance(targets: targets, base: base) + inventoryAcceptance = acceptance + runner = InventoryAcceptanceRunner(base: base, acceptance: acceptance) + } else { + inventoryAcceptance = nil + runner = ProcessCommandRunner() + } + acceptanceError = nil + } catch { + inventoryAcceptance = nil + runner = ProcessCommandRunner() + acceptanceError = error + } +#else + runner = ProcessCommandRunner() + acceptanceError = acceptanceRequested ? ToolkitError(.unsupported, message: "Inventory acceptance requires a Debug build.", recovery: "Build the Debug configuration and rerun the native acceptance test.", technicalDetail: nil) : nil +#endif + let acceptanceSimulators: SimulatorClient? +#if DEBUG + acceptanceSimulators = inventoryAcceptance == nil ? nil : SimulatorClient(runner: runner) +#else + acceptanceSimulators = nil +#endif + // UI tests exclude physical discovery; only the explicit acceptance launch enables simulators. + let configuration: DeviceDiscovery.Configuration = uiTesting || acceptanceRequested + ? .init(usbmux: USBMuxClient(socketPath: "/nonexistent/ui-testing-usbmuxd"), coreDevice: nil, simulators: acceptanceSimulators, enrichWithLockdown: false) : .init() discovery = DeviceDiscovery(configuration: configuration) developerImage = DeveloperImageModel() @@ -150,6 +186,7 @@ final class AppModel { if let destination = arguments.firstIndex(of: "-workspace").flatMap({ arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil }).flatMap(Workspace.init(rawValue:)) { workspace = destination } + if let acceptanceError { presentedError = PresentedError(acceptanceError) } } // MARK: Lifecycle @@ -208,7 +245,12 @@ final class AppModel { // MARK: Devices var allDevices: [Device] { - demoMode ? [DemoMode.device] + snapshot.devices : snapshot.devices +#if DEBUG + if let inventoryAcceptance { + return snapshot.devices.filter { $0.kind == .simulator && inventoryAcceptance.targets.contains($0.udid) } + } +#endif + return demoMode ? [DemoMode.device] + snapshot.devices : snapshot.devices } var physicalDevices: [Device] { allDevices.filter { $0.kind == .physical || $0.kind == .demo } } diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index 860db24..f6cb720 100644 --- a/App/iOSDeveloperToolkit/Model/FeatureModels.swift +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -24,6 +24,13 @@ struct AppRow: Identifiable, Hashable, Sendable { var sizeText: String { ByteFormatting.string(sizeBytes) } } +/// Captures the row, inventory generation and device named by the confirmation. +struct AppInventoryAction: Sendable { + let row: AppRow + let device: Device + let inventoryID: UUID +} + @Observable @MainActor final class AppsModel { @@ -36,6 +43,37 @@ final class AppsModel { var sortOrder = [KeyPathComparator(\AppRow.name, comparator: .localizedStandard)] var selection: Set = [] var isLoading = false + private var refreshID = UUID() + private var inventoryID = UUID() + private var refreshOperation: RunningOperation? + private var changingApps: Set = [] + + func selectionChanged() { + selection = [] + refreshID = UUID() + inventoryID = UUID() + refreshOperation?.cancel() + refreshOperation = nil + isLoading = false + } + + func canAct(on row: AppRow, device: Device, selectedTarget: DeviceTarget?) -> Bool { + selectedTarget == device.target && loadedFor == device.target && rows.contains(row) && !isLoading + && !changingApps.contains(row.id) && device.kind != .demo + } + + func prepareAction(for row: AppRow, device: Device, selectedTarget: DeviceTarget?) throws -> AppInventoryAction { + guard canAct(on: row, device: device, selectedTarget: selectedTarget) else { + throw ToolkitError(.invalidInput, message: "Load the current device’s apps before using this row.", recovery: "Refresh Installed Apps and choose the app again.", technicalDetail: nil) + } + return AppInventoryAction(row: row, device: device, inventoryID: inventoryID) + } + + private func validate(_ request: AppInventoryAction, selectedTarget: DeviceTarget?) throws { + guard request.inventoryID == inventoryID && canAct(on: request.row, device: request.device, selectedTarget: selectedTarget) else { + throw ToolkitError(.invalidInput, message: "The app list changed after this action was confirmed.", recovery: "Refresh Installed Apps and confirm the action again.", technicalDetail: nil) + } + } var visibleRows: [AppRow] { let filtered = rows.filter { row in @@ -47,11 +85,25 @@ final class AppsModel { func refresh(app: AppModel, device: Device) async { let target = device.target + guard app.selectedTarget == target else { + app.present(ToolkitError(.invalidInput, message: "The selected device changed before its apps could load.", recovery: "Select the device and load Installed Apps again.", technicalDetail: nil)) + return + } let runner = app.runner let sizes = calculateSizes + let generation = UUID() + refreshID = generation + inventoryID = UUID() + refreshOperation?.cancel() + selection = [] isLoading = true - defer { isLoading = false } - let result: (rows: [AppRow], source: String)? = await app.run("Load installed apps", workspace: .apps, target: target, transport: device.kind == .simulator ? "simctl listapps" : (device.supportsLockdownServices ? "installation_proxy" : "devicectl device info apps")) { _ in + defer { + if refreshID == generation { + isLoading = false + refreshOperation = nil + } + } + let result: (rows: [AppRow], source: String)? = await app.run("Load installed apps", workspace: .apps, target: target, transport: device.kind == .simulator ? "simctl listapps" : (device.supportsLockdownServices ? "installation_proxy" : "devicectl device info apps"), onStart: { self.refreshOperation = $0 }) { _ in switch device.kind { case .simulator: let apps = try await SimulatorClient(runner: runner).apps(target) @@ -73,16 +125,43 @@ final class AppsModel { ], "Demo data") } } - if let result { + if let result, refreshID == generation { rows = result.rows source = result.source loadedFor = target + inventoryID = UUID() + } + } + + func launch(_ request: AppInventoryAction, app: AppModel) async { + do { try validate(request, selectedTarget: app.selectedTarget) } catch { app.present(error); return } + guard let action = ActionCatalog.descriptor("launch-app") else { + app.present(ToolkitError(.internalInconsistency, message: "The Launch App action is unavailable.", recovery: nil, technicalDetail: nil)) + return } + let row = request.row + let target = request.device.target + let executor = app.executor + changingApps.insert(row.id) + defer { changingApps.remove(row.id) } + if let result = await app.run("Launch \(row.name)", workspace: .apps, target: target, transport: action.mechanism, { _ in + try await executor.execute(action, target: target, values: ["bundle": row.bundleIdentifier]) + }) { app.statusMessage = result.summary } } - func uninstall(_ row: AppRow, app: AppModel, device: Device) async { + func uninstall(_ request: AppInventoryAction, app: AppModel) async { + do { + try validate(request, selectedTarget: app.selectedTarget) + guard request.row.isRemovable else { + throw ToolkitError(.unsupported, message: "This app cannot be removed.", recovery: nil, technicalDetail: nil) + } + } catch { app.present(error); return } + let row = request.row + let device = request.device let target = device.target let runner = app.runner + changingApps.insert(row.id) + defer { changingApps.remove(row.id) } let succeeded = await app.run("Remove \(row.name)", workspace: .apps, target: target, transport: device.kind == .simulator ? "simctl uninstall" : "installation_proxy / devicectl", argv: [row.bundleIdentifier]) { operation in switch device.kind { case .simulator: @@ -101,7 +180,10 @@ final class AppsModel { return true } if succeeded == true { - rows.removeAll { $0.id == row.id } + if loadedFor == target && inventoryID == request.inventoryID { + rows.removeAll { $0.id == row.id } + selection.remove(row.id) + } app.statusMessage = "Removed \(row.name) from \(target.name)." } } diff --git a/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift new file mode 100644 index 0000000..5b172b1 --- /dev/null +++ b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift @@ -0,0 +1,196 @@ +#if DEBUG +import AppKit +import DeviceKit +import Foundation +import Observation +import ToolkitCore + +/// The two test-owned simulators exposed by the native inventory acceptance launch. +struct InventoryAcceptanceTargets: Sendable { + let first: String + let second: String + let owner: String + + static func parse(arguments: [String], uiTesting: Bool, demoMode: Bool) throws(ToolkitError) -> InventoryAcceptanceTargets? { + let firstFlags = arguments.indices.filter { arguments[$0] == "-inventory-acceptance-a" } + let secondFlags = arguments.indices.filter { arguments[$0] == "-inventory-acceptance-b" } + let ownerFlags = arguments.indices.filter { arguments[$0] == "-inventory-acceptance-owner" } + guard !firstFlags.isEmpty || !secondFlags.isEmpty || !ownerFlags.isEmpty else { return nil } + guard uiTesting, !demoMode, firstFlags.count == 1, secondFlags.count == 1, ownerFlags.count == 1, + let firstIndex = firstFlags.first, arguments.indices.contains(firstIndex + 1), + let secondIndex = secondFlags.first, arguments.indices.contains(secondIndex + 1), + let ownerIndex = ownerFlags.first, arguments.indices.contains(ownerIndex + 1), + let first = UUID(uuidString: arguments[firstIndex + 1]), + let second = UUID(uuidString: arguments[secondIndex + 1]), first != second, + let owner = UUID(uuidString: arguments[ownerIndex + 1]) else { + throw ToolkitError(.invalidInput, message: "The inventory acceptance launch requires two distinct simulator UUIDs and an owner UUID.", recovery: "Use a Debug build with -ui-testing YES, -demo-mode NO and one value for each inventory acceptance target and owner.", technicalDetail: nil) + } + return InventoryAcceptanceTargets(first: first.uuidString, second: second.uuidString, owner: owner.uuidString) + } + + func contains(_ identifier: String) -> Bool { + let normalized = identifier.uppercased() + return normalized == first || normalized == second + } +} + +/// An observed AppKit snapshot, without changing activation, focus or selection. +struct InventoryWindowReadiness: Sendable { + let applicationActive: Bool + let mainWindowPresent: Bool + let keyWindowPresent: Bool + let mainWindowKey: Bool + let keyWindowMatchesMain: Bool + + var isReady: Bool { + applicationActive && mainWindowPresent && keyWindowPresent && mainWindowKey && keyWindowMatchesMain + } + + var diagnostic: String { + "window-ready: \(isReady); app-active: \(applicationActive); main-present: \(mainWindowPresent); key-present: \(keyWindowPresent); main-key: \(mainWindowKey); key-is-main: \(keyWindowMatchesMain)" + } +} + +/// Holds one successful real A inventory reply until the native UI test explicitly releases it. +/// Cancellation deliberately does not discard that reply: the production inventory generation +/// check must reject its late delivery after B has loaded. Delivery is acknowledged only after +/// that real inventory refresh returns to its UI caller. The wait has an independent deadline. +@Observable +@MainActor +final class InventoryAcceptance { + enum State: String { + case idle, armed, held, released, delivered, expired + } + + let targets: InventoryAcceptanceTargets + private(set) var state: State = .idle + private(set) var isDeletingFirst = false + private(set) var selectionInputTrace: [String] = [] + private(set) var windowReadiness: InventoryWindowReadiness? = nil + private let base: ProcessCommandRunner + private var held: (result: CommandResult, continuation: CheckedContinuation)? + private var deadline: Task? + + init(targets: InventoryAcceptanceTargets, base: ProcessCommandRunner) { + self.targets = targets + self.base = base + } + + /// Observes native table binding writes without changing the selection or action policy. + func recordSelectionInput(previous: Set, next: Set) { + let before = previous.sorted().joined(separator: ",").prefix(256) + let after = next.sorted().joined(separator: ",").prefix(256) + selectionInputTrace = Array((selectionInputTrace + ["[\(before)] → [\(after)]"]).suffix(8)) + } + + func recordWindowReadiness(application: NSApplication) { + let mainWindow: NSWindow? = application.mainWindow + let keyWindow: NSWindow? = application.keyWindow + windowReadiness = InventoryWindowReadiness( + applicationActive: application.isActive, + mainWindowPresent: mainWindow != nil, + keyWindowPresent: keyWindow != nil, + mainWindowKey: mainWindow?.isKeyWindow == true, + keyWindowMatchesMain: mainWindow != nil && keyWindow != nil && keyWindow === mainWindow + ) + } + + func arm() throws(ToolkitError) { + guard state == .idle else { + throw ToolkitError(.invalidInput, message: "The inventory acceptance gate has already been armed.", recovery: "Relaunch the acceptance test to capture another inventory reply.", technicalDetail: nil) + } + state = .armed + } + + func release() throws(ToolkitError) { + guard let held, state == .held else { + throw ToolkitError(.invalidInput, message: "There is no captured inventory reply to release.", recovery: "Wait for the acceptance gate to report held before releasing it.", technicalDetail: nil) + } + self.held = nil + deadline?.cancel() + deadline = nil + state = .released + held.continuation.resume(returning: held.result) + } + + func inventoryRefreshReturned(target: String) { + if state == .released && target.uppercased() == targets.first { + state = .delivered + } + } + + /// Deletes only the A simulator whose fresh UUID and exact owner-bound name match this run. + /// The UI test invokes this inside the observed confirmation sheet to cause real target loss. + func deleteFirstSimulator(target: DeviceTarget) async throws { + guard state == .delivered else { + throw ToolkitError(.invalidInput, message: "The late inventory reply has not completed acceptance yet.", recovery: "Wait for the inventory gate to report delivered before deleting the owned test simulator.", technicalDetail: nil) + } + guard target.kind == .simulator, target.udid.uppercased() == targets.first, !isDeletingFirst else { + throw ToolkitError(.invalidInput, message: "Only this acceptance run’s first owned simulator can be deleted.", recovery: "Use the confirmation for simulator A created by this acceptance run.", technicalDetail: nil) + } + isDeletingFirst = true + defer { isDeletingFirst = false } + let client = SimulatorClient(runner: base) + let records = try await client.list() + guard let record = records.first(where: { $0.udid.uppercased() == targets.first }), + record.name == "IDT Inventory A \(targets.owner)" else { + throw ToolkitError(.invalidInput, message: "The simulator does not match this acceptance run’s ownership marker.", recovery: "Stop the test and inspect its fixture setup; no simulator was deleted.", technicalDetail: nil) + } + try await client.shutdown(record.device.target) + let deletion = try await base.run(try XcodeTool.simctl.request(["delete", targets.first], timeout: 120, displayName: "simctl delete owned acceptance simulator")) + guard deletion.succeeded else { + throw ToolkitError(.commandFailed, message: "Deleting the owned acceptance simulator failed.", recovery: "Inspect the simulator fixture failure and rerun the acceptance test.", technicalDetail: deletion.technicalSummary) + } + guard try await !client.list().contains(where: { $0.udid.uppercased() == targets.first }) else { + throw ToolkitError(.protocolViolation, message: "The owned acceptance simulator still exists after deletion.", recovery: "Inspect the simulator service before rerunning the acceptance test.", technicalDetail: nil) + } + } + + func holdIfArmed(_ result: CommandResult) async throws -> CommandResult { + guard state == .armed, result.succeeded, + result.request.arguments == ["simctl", "listapps", targets.first] else { return result } + return try await withCheckedThrowingContinuation { continuation in + held = (result, continuation) + state = .held + deadline = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(180)) + } catch is CancellationError { + return + } catch { + self?.expire(cause: error.localizedDescription) + return + } + self?.expire(cause: nil) + } + } + } + + private func expire(cause: String?) { + guard let held, state == .held else { return } + self.held = nil + deadline = nil + state = .expired + held.continuation.resume(throwing: ToolkitError(.timedOut, message: "The inventory acceptance gate was not released within 180 seconds.", recovery: "Inspect the native acceptance failure and rerun the test.", technicalDetail: cause)) + } +} + +/// All commands and streams use the production runner; only one completed listapps reply waits. +struct InventoryAcceptanceRunner: OwnedCommandRunning { + let base: ProcessCommandRunner + let acceptance: InventoryAcceptance + + func run(_ request: CommandRequest) async throws -> CommandResult { + let result = try await base.run(request) + return try await acceptance.holdIfArmed(result) + } + + func stream(_ request: CommandRequest) -> AsyncThrowingStream { + base.stream(request) + } + + func stream(_ request: CommandRequest, cancellation: CommandCancellation) -> AsyncThrowingStream { + base.stream(request, cancellation: cancellation) + } +} +#endif diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index 06597e8..ea8b94b 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -2,6 +2,10 @@ import DeviceKit import SwiftUI import ToolkitCore import ToolkitFeatures +#if DEBUG +import AppKit +import Combine +#endif struct AppsView: View { @Environment(AppModel.self) private var model @@ -11,10 +15,42 @@ struct AppsView: View { @Bindable var apps = model.apps VStack(alignment: .leading, spacing: 12) { TargetHeader() +#if DEBUG + if let acceptance = model.inventoryAcceptance { + HStack { + Button("Arm inventory acceptance") { + do { try acceptance.arm() } catch { model.present(error) } + } + .disabled(acceptance.state != .idle) + .accessibilityIdentifier("inventory-gate-arm") + Button("Release captured inventory") { + do { try acceptance.release() } catch { model.present(error) } + } + .disabled(acceptance.state != .held) + .accessibilityIdentifier("inventory-gate-release") + Text(acceptance.state.rawValue) + .accessibilityIdentifier("inventory-gate-state") + Text("Selection input") + .font(.caption) + .lineLimit(1) + .accessibilityIdentifier("inventory-selection-input") + .accessibilityValue(Text(verbatim: "writes: \(acceptance.selectionInputTrace.joined(separator: "; ")); current: \(apps.selection.sorted().joined(separator: ",").prefix(256)); loading: \(apps.isLoading); \(acceptance.windowReadiness.map(\.diagnostic) ?? "window-readiness: unobserved")")) + } + .onAppear { acceptance.recordWindowReadiness(application: .shared) } + .onReceive(inventoryWindowReadinessEvents) { _ in + acceptance.recordWindowReadiness(application: .shared) + } + } +#endif if let device = model.selectedDevice { HStack(spacing: 12) { Button { - Task { await apps.refresh(app: model, device: device) } + Task { + await apps.refresh(app: model, device: device) +#if DEBUG + model.inventoryAcceptance?.inventoryRefreshReturned(target: device.target.udid) +#endif + } } label: { Label(apps.loadedFor == device.target ? "Refresh" : "Load Apps", systemImage: "arrow.clockwise") } @@ -33,21 +69,25 @@ struct AppsView: View { if apps.loadedFor != nil && apps.loadedFor != device.target { Label("This list is from another device. Refresh to load \(device.name).", systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) + .accessibilityIdentifier("apps-stale-inventory") } - Table(apps.visibleRows, selection: $apps.selection, sortOrder: $apps.sortOrder) { - TableColumn("Name", value: \.name) + Table(apps.visibleRows, selection: appSelection, sortOrder: $apps.sortOrder) { + TableColumn("Name", value: \.name) { Text($0.name).accessibilityIdentifier("installed-app-\($0.bundleIdentifier)") } TableColumn("Bundle ID", value: \.bundleIdentifier) { Text($0.bundleIdentifier).font(.callout.monospaced()) } TableColumn("Version", value: \.version) { Text($0.build.isEmpty ? $0.version : "\($0.version) (\($0.build))") } TableColumn("Type", value: \.kind) TableColumn("Size") { Text($0.sizeText).monospacedDigit() } .width(min: 70, ideal: 90) } + .accessibilityIdentifier("installed-apps-table") .contextMenu(forSelectionType: AppRow.ID.self) { ids in if let id = ids.first, let row = apps.rows.first(where: { $0.id == id }) { Button("Copy Bundle ID") { Pasteboard.copy(row.bundleIdentifier) } - Button("Launch") { launch(row, device) }.disabled(device.kind == .demo) + Button("Launch") { launch(row, device) }.disabled(!apps.canAct(on: row, device: device, selectedTarget: model.selectedTarget)) + .accessibilityIdentifier("apps-context-launch") Divider() - Button("Remove…", role: .destructive) { confirmRemove(row, device) }.disabled(!row.isRemovable || device.kind == .demo) + Button("Remove…", role: .destructive) { confirmRemove(row, device) }.disabled(!row.isRemovable || !apps.canAct(on: row, device: device, selectedTarget: model.selectedTarget)) + .accessibilityIdentifier("apps-context-remove") } } .overlay { @@ -59,39 +99,88 @@ struct AppsView: View { Text(apps.rows.isEmpty ? "" : "\(apps.visibleRows.count) of \(apps.rows.count) apps · source: \(apps.source)") .font(.caption) .foregroundStyle(.secondary) + .accessibilityIdentifier("apps-inventory-source") Spacer() if let id = apps.selection.first, let row = apps.rows.first(where: { $0.id == id }) { Button("Copy Bundle ID") { Pasteboard.copy(row.bundleIdentifier) } - Button("Launch") { launch(row, device) }.disabled(device.kind == .demo) + Button("Launch") { launch(row, device) }.disabled(!apps.canAct(on: row, device: device, selectedTarget: model.selectedTarget)) + .accessibilityIdentifier("apps-launch") Button("Remove…", role: .destructive) { confirmRemove(row, device) } - .disabled(!row.isRemovable || device.kind == .demo) + .disabled(!row.isRemovable || !apps.canAct(on: row, device: device, selectedTarget: model.selectedTarget)) + .accessibilityIdentifier("apps-remove") } } } } .padding(20) + .onChange(of: model.selectedTarget) { _, _ in confirmation = nil } .sheet(item: $confirmation) { pending in - ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) + let confirmationSheet = ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) +#if DEBUG + if let acceptance = model.inventoryAcceptance, let target = pending.target, + target.kind == .simulator, target.udid.uppercased() == acceptance.targets.first { + VStack { + confirmationSheet + Button("Delete owned test simulator A") { + Task { + await model.run("Delete owned test simulator A", workspace: .apps, target: target, transport: "simctl shutdown/delete") { _ in + try await acceptance.deleteFirstSimulator(target: target) + } + } + } + .disabled(acceptance.isDeletingFirst || acceptance.state != .delivered) + .accessibilityIdentifier("inventory-delete-owned-a") + .padding(.bottom, 20) + } + } else { + confirmationSheet + } +#else + confirmationSheet +#endif } } - private func confirmRemove(_ row: AppRow, _ device: Device) { - confirmation = PendingConfirmation(title: "Remove \(row.name)?", detail: "\(row.name) (\(row.bundleIdentifier)) and its data will be deleted from \(device.name).", requirement: .make(for: .highImpact, target: device.target), target: device.target) { - Task { await model.apps.uninstall(row, app: model, device: device) } +#if DEBUG + private var inventoryWindowReadinessEvents: Publishers.MergeMany { + let events: [Notification.Name] = [ + NSApplication.didBecomeActiveNotification, NSApplication.didResignActiveNotification, + NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification, + NSWindow.didBecomeMainNotification, NSWindow.didResignMainNotification, + ] + return Publishers.MergeMany(events.map { NotificationCenter.default.publisher(for: $0, object: nil) }) + } +#endif + + private var appSelection: Binding> { + @Bindable var apps = model.apps +#if DEBUG + if let acceptance = model.inventoryAcceptance { + return Binding(get: { apps.selection }, set: { next in + acceptance.recordSelectionInput(previous: apps.selection, next: next) + apps.selection = next + }) } +#endif + return $apps.selection + } + + private func confirmRemove(_ row: AppRow, _ device: Device) { + do { + let request = try model.apps.prepareAction(for: row, device: device, selectedTarget: model.selectedTarget) + confirmation = PendingConfirmation(title: "Remove \(row.name)?", detail: "\(row.name) (\(row.bundleIdentifier)) and its data will be deleted from \(device.name).", requirement: .make(for: .highImpact, target: request.device.target), target: request.device.target) { + Task { await model.apps.uninstall(request, app: model) } + } + } catch { model.present(error) } } private func launch(_ row: AppRow, _ device: Device) { - guard let action = ActionCatalog.descriptor("launch-app") else { return } - confirmation = PendingConfirmation(title: "Launch \(row.name)?", detail: "The app is restarted if it is already running.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { - let executor = model.executor - let target = device.target - Task { - if let result = await model.run("Launch \(row.name)", workspace: .apps, target: target, transport: action.mechanism, { _ in try await executor.execute(action, target: target, values: ["bundle": row.bundleIdentifier]) }) { - model.statusMessage = result.summary - } + do { + let request = try model.apps.prepareAction(for: row, device: device, selectedTarget: model.selectedTarget) + confirmation = PendingConfirmation(title: "Launch \(row.name)?", detail: "The app is restarted on \(device.name) if it is already running.", requirement: .make(for: .deviceChange, target: request.device.target), target: request.device.target) { + Task { await model.apps.launch(request, app: model) } } - } + } catch { model.present(error) } } } diff --git a/App/iOSDeveloperToolkit/Views/ContentView.swift b/App/iOSDeveloperToolkit/Views/ContentView.swift index 903ad9c..c3da0b4 100644 --- a/App/iOSDeveloperToolkit/Views/ContentView.swift +++ b/App/iOSDeveloperToolkit/Views/ContentView.swift @@ -289,6 +289,7 @@ struct TargetPicker: View { } label: { Label("\(device.name) — \(device.displayVersion)\(device.simulatorState == .booted ? " (running)" : "")", systemImage: "\(device.family.symbolName)") } + .accessibilityIdentifier("target-simulator-\(device.udid)") } } if model.selectedDeviceID != nil { @@ -302,6 +303,7 @@ struct TargetPicker: View { Text(model.selectedDevice?.name ?? "Choose a Device") .font(.headline) .lineLimit(1) + .truncationMode(.middle) if let device = model.selectedDevice { Text(device.kind.label) .font(.caption2) @@ -311,7 +313,8 @@ struct TargetPicker: View { } } .menuIndicator(.visible) - .fixedSize() + .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: 140, idealWidth: 200, maxWidth: 240, alignment: .leading) .help("Choose the device or simulator that actions affect") .accessibilityIdentifier("target-picker") } diff --git a/Sources/DeviceKit/Discovery/DeviceDiscovery.swift b/Sources/DeviceKit/Discovery/DeviceDiscovery.swift index afed333..175731a 100644 --- a/Sources/DeviceKit/Discovery/DeviceDiscovery.swift +++ b/Sources/DeviceKit/Discovery/DeviceDiscovery.swift @@ -406,12 +406,16 @@ public actor DeviceDiscovery { } } + /// A cancelled refresh cannot replace the last completed simulator snapshot. private func refreshSimulators() async { - guard let client = configuration.simulators else { return } + guard !Task.isCancelled, let client = configuration.simulators else { return } do { - simulatorRecords = try await client.list() + let records = try await client.list() + guard !Task.isCancelled else { return } + simulatorRecords = records snapshot.simulators = .available(count: simulatorRecords.filter(\.isAvailable).count) } catch { + guard !Task.isCancelled else { return } simulatorRecords = [] let reason = (error as? ToolkitError).map { $0.kind == .toolMissing ? "Xcode is not installed, so simulators are unavailable." : $0.message } ?? error.localizedDescription snapshot.simulators = .unavailable(reason: reason) diff --git a/Tests/ToolkitCoreTests/CommandRunnerTests.swift b/Tests/ToolkitCoreTests/CommandRunnerTests.swift index c0506bf..4f6fe6a 100644 --- a/Tests/ToolkitCoreTests/CommandRunnerTests.swift +++ b/Tests/ToolkitCoreTests/CommandRunnerTests.swift @@ -101,10 +101,12 @@ struct CommandRunnerTests { defer { close(descriptor) } // The child acknowledges SIGTERM but waits for a test-controlled release before exit. // A FIFO makes each assertion depend on a real process state rather than a delay. + // Only the owned shell's TERM handling is under test; its idle child is fixture cleanup. let script = """ - trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; printf "after release\\n"; exit 0' TERM + trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; kill -KILL "$child" 2>/dev/null; wait "$child" 2>/dev/null; printf "after release\\n"; exit 0' TERM + sleep 30 & child=$! printf 'ready:%s\\n' "$$" - IFS= read -r hold < "$1" + wait "$child" exit 1 """ let request = CommandRequest(executable: URL(fileURLWithPath: "/bin/sh"), arguments: ["-c", script, "owned-command", barrier.path], diff --git a/iOSDeveloperToolkit.xcodeproj/project.pbxproj b/iOSDeveloperToolkit.xcodeproj/project.pbxproj index 0880dad..4b0909f 100644 --- a/iOSDeveloperToolkit.xcodeproj/project.pbxproj +++ b/iOSDeveloperToolkit.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0046784D38773E756D16B381 /* DeviceViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192082EDB2AA8A31B4DE9A43 /* DeviceViews.swift */; }; + 03C6EC438B21C4A148298B5A /* InventoryAcceptance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644C9380AD3F95F4A9D88B5F /* InventoryAcceptance.swift */; }; 0E51C7116150795B48ED38A0 /* ReadinessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E525F882B8476846CFA326 /* ReadinessView.swift */; }; 1061CDB40FBAC1EEE77206D5 /* DeveloperImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1B9D11BA0887FAFECDC9DF /* DeveloperImageView.swift */; }; 1623B3509BB855616EA203F0 /* iOSDeveloperToolkitApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC5D72518E6E2101A0B339B /* iOSDeveloperToolkitApp.swift */; }; @@ -17,6 +18,7 @@ 25D059CF99AA7F74E66147F4 /* SmokeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2095C064BD8BE67389318E84 /* SmokeUITests.swift */; }; 26019A7B916688FEE3A23585 /* LogViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81EECBCB7D517436001EFF8 /* LogViews.swift */; }; 296F9F5D4685AB10A7B27CA6 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791078D9EF3EC6308839270F /* AppModel.swift */; }; + 3C9616CF740BAB500DD923FA /* SimulatorInventoryUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B1B6EF0D1B0F74DE215798 /* SimulatorInventoryUITests.swift */; }; 42E12E11EB7BA75B9D50C3E8 /* ReconnectGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26E835D20074A0489176A15 /* ReconnectGuideView.swift */; }; 4610E70974BDF2EAD32B8303 /* FirmwareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32872FC2AF66DD024EA035E2 /* FirmwareView.swift */; }; 493D4952FA985AF0CA0A7AC5 /* SecurityAnalysisView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FE5991933CA062A7745B /* SecurityAnalysisView.swift */; }; @@ -57,6 +59,7 @@ /* Begin PBXFileReference section */ 117839C38E19BA74C82D8382 /* ReferenceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceViews.swift; sourceTree = ""; }; + 14599347238BF6D17C5DD92D /* IOSDeveloperToolkit-Swift */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "IOSDeveloperToolkit-Swift"; path = .; sourceTree = SOURCE_ROOT; }; 192082EDB2AA8A31B4DE9A43 /* DeviceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceViews.swift; sourceTree = ""; }; 2095C064BD8BE67389318E84 /* SmokeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeUITests.swift; sourceTree = ""; }; 32872FC2AF66DD024EA035E2 /* FirmwareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareView.swift; sourceTree = ""; }; @@ -65,6 +68,7 @@ 585ECC84A09B869053F28246 /* LocationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationModel.swift; sourceTree = ""; }; 599C314C6B21A9A615B04C7A /* iOSDeveloperToolkit.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOSDeveloperToolkit.entitlements; sourceTree = ""; }; 5FC5D72518E6E2101A0B339B /* iOSDeveloperToolkitApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSDeveloperToolkitApp.swift; sourceTree = ""; }; + 644C9380AD3F95F4A9D88B5F /* InventoryAcceptance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InventoryAcceptance.swift; sourceTree = ""; }; 64CADCDAC59205AFA532EF87 /* ActionsViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionsViews.swift; sourceTree = ""; }; 791078D9EF3EC6308839270F /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; 7F724AF45C9EBAFFC75A5710 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; @@ -75,7 +79,6 @@ B26E835D20074A0489176A15 /* ReconnectGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReconnectGuideView.swift; sourceTree = ""; }; B37EBEC3A6576895CA18665A /* DataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataViews.swift; sourceTree = ""; }; B81EECBCB7D517436001EFF8 /* LogViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViews.swift; sourceTree = ""; }; - BF28B9563BB119B8B18C547A /* IOSDeveloperToolkit-Swift */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "IOSDeveloperToolkit-Swift"; path = .; sourceTree = SOURCE_ROOT; }; C28F7F2B73F9F0983878D44D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; C6641462093807C7726C77E6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLogsModel.swift; sourceTree = ""; }; @@ -86,6 +89,7 @@ E7E525F882B8476846CFA326 /* ReadinessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadinessView.swift; sourceTree = ""; }; EC558873395BD2D10988B49F /* LocationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationViews.swift; sourceTree = ""; }; EF4B82A9EAB2FEBF4FBE2FA2 /* AppsViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsViews.swift; sourceTree = ""; }; + F0B1B6EF0D1B0F74DE215798 /* SimulatorInventoryUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorInventoryUITests.swift; sourceTree = ""; }; F10F7407E0C32BF6634D85B5 /* ScreenshotHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotHarness.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -110,6 +114,7 @@ A42E01F498D8AE9BAE52EA83 /* DeveloperImageModel.swift */, D34F678B9113D08044E08644 /* FeatureModels.swift */, DA238102E7E6CB9B5DB927EC /* FirmwareModel.swift */, + 644C9380AD3F95F4A9D88B5F /* InventoryAcceptance.swift */, CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */, 585ECC84A09B869053F28246 /* LocationModel.swift */, F10F7407E0C32BF6634D85B5 /* ScreenshotHarness.swift */, @@ -139,6 +144,7 @@ 60C4D44F4E03EAEE6FC8028C /* UITests */ = { isa = PBXGroup; children = ( + F0B1B6EF0D1B0F74DE215798 /* SimulatorInventoryUITests.swift */, 2095C064BD8BE67389318E84 /* SmokeUITests.swift */, ); path = UITests; @@ -190,7 +196,7 @@ 9F007AA80ECE5A10D372536D /* Packages */ = { isa = PBXGroup; children = ( - BF28B9563BB119B8B18C547A /* IOSDeveloperToolkit-Swift */, + 14599347238BF6D17C5DD92D /* IOSDeveloperToolkit-Swift */, ); name = Packages; sourceTree = ""; @@ -414,6 +420,7 @@ 9F456A81A2AF3E4B9BA9E1A0 /* FeatureModels.swift in Sources */, 7E5687D6621795076AAED626 /* FirmwareModel.swift in Sources */, 4610E70974BDF2EAD32B8303 /* FirmwareView.swift in Sources */, + 03C6EC438B21C4A148298B5A /* InventoryAcceptance.swift in Sources */, 1BEAAB3DA748A2FF696A37BE /* LiveLogsModel.swift in Sources */, 932BE70EA34036FB92844B1C /* LocationModel.swift in Sources */, 16410705637BF3C36BD60ADB /* LocationViews.swift in Sources */, @@ -433,6 +440,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3C9616CF740BAB500DD923FA /* SimulatorInventoryUITests.swift in Sources */, 25D059CF99AA7F74E66147F4 /* SmokeUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/scripts/SimulatorInventoryFixtures.swift b/scripts/SimulatorInventoryFixtures.swift new file mode 100644 index 0000000..f936e1f --- /dev/null +++ b/scripts/SimulatorInventoryFixtures.swift @@ -0,0 +1,276 @@ +import Foundation +import ToolkitCore + +/// Hosted fixture ownership stays outside the sandboxed UI-test runner. Every created +/// simulator is registered before further work, and cleanup verifies its UUID and exact name. +@main +struct SimulatorInventoryFixtures { + struct Manifest: Codable { + let first: String? + let second: String? + let owner: String + + enum CodingKeys: String, CodingKey { case first, second, owner } + + func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(first, forKey: .first) + try values.encode(second, forKey: .second) + try values.encode(owner, forKey: .owner) + } + } + + struct Simulator { + let identifier: String + let name: String + let state: String + let type: String + let runtime: String + let available: Bool + } + + static func main() async throws { + let arguments = CommandLine.arguments + guard arguments.count == 3 else { + throw failure("Use inventory-fixtures prepare DIRECTORY or cleanup DIRECTORY.", cause: nil) + } + let directory = URL(fileURLWithPath: arguments[2], isDirectory: true).standardizedFileURL + guard let temporary = ProcessInfo.processInfo.environment["RUNNER_TEMP"], + directory.resolvingSymlinksInPath().path.hasPrefix(URL(fileURLWithPath: temporary).resolvingSymlinksInPath().path + "/") else { + throw failure("The fixture directory must be a dedicated child of RUNNER_TEMP.", cause: nil) + } + let runner = ProcessCommandRunner() + switch arguments[1] { + case "prepare": try await prepare(directory: directory, runner: runner) + case "cleanup": try await cleanup(directory: directory, runner: runner) + default: throw failure("The fixture command must be prepare or cleanup.", cause: nil) + } + } + + static func prepare(directory: URL, runner: ProcessCommandRunner) async throws { + try SecureFileIO.createNewPrivateDirectory(at: directory) + let owner = UUID().uuidString + var manifest = Manifest(first: nil, second: nil, owner: owner) + try save(manifest, directory: directory) + let runtimeJSON = try JSONValue.parse(try await command(["simctl", "list", "runtimes", "--json"], timeout: 60, runner: runner).standardOutput) + let typeJSON = try JSONValue.parse(try await command(["simctl", "list", "devicetypes", "--json"], timeout: 60, runner: runner).standardOutput) + guard let runtimes = runtimeJSON["runtimes"]?.array, let types = typeJSON["devicetypes"]?.array else { + throw failure("simctl did not return runtime and device-type arrays.", cause: nil) + } + let availableRuntimes = try runtimes.filter { try boolean($0, field: "isAvailable") }.map { try string($0, field: "identifier") } + let iphoneTypes = try types.filter { try string($0, field: "name").hasPrefix("iPhone") }.map { try string($0, field: "identifier") } + let records = try await simulators(runner: runner) + guard let template = records.first(where: { + $0.available && availableRuntimes.contains($0.runtime) && iphoneTypes.contains($0.type) + && $0.runtime.hasPrefix("com.apple.CoreSimulator.SimRuntime.iOS-") + && $0.type.hasPrefix("com.apple.CoreSimulator.SimDeviceType.iPhone-") + }) else { + throw failure("The hosted runner needs an available iPhone simulator runtime and device type.", cause: nil) + } + let first = try await create(name: "IDT Inventory A \(owner)", template: template, runner: runner) + manifest = Manifest(first: first, second: nil, owner: owner) + try await register(manifest, identifier: first, name: "IDT Inventory A \(owner)", directory: directory, runner: runner) + let second = try await create(name: "IDT Inventory B \(owner)", template: template, runner: runner) + manifest = Manifest(first: first, second: second, owner: owner) + try await register(manifest, identifier: second, name: "IDT Inventory B \(owner)", directory: directory, runner: runner) + for identifier in [first, second] { + _ = try await command(["simctl", "bootstatus", identifier, "-b"], timeout: 600, runner: runner) + } + let firstApp = try await fixture(bundle: "io.hideouts.idt.inventory.a", name: "000 Inventory A", directory: directory, runner: runner) + let secondApp = try await fixture(bundle: "io.hideouts.idt.inventory.b", name: "000 Inventory B", directory: directory, runner: runner) + let sharedApp = try await fixture(bundle: "io.hideouts.idt.inventory.shared", name: "001 Inventory Shared", directory: directory, runner: runner) + for (identifier, app) in [(first, firstApp), (first, sharedApp), (second, secondApp), (second, sharedApp)] { + _ = try await command(["simctl", "install", identifier, app.path], timeout: 600, runner: runner) + } + print("[inventory fixtures] Two owned simulators and their isolated app inventories are ready.") + } + + static func cleanup(directory: URL, runner: ProcessCommandRunner) async throws { + let manifest = try load(directory: directory) + let owned = [(manifest.first, "IDT Inventory A \(manifest.owner)"), (manifest.second, "IDT Inventory B \(manifest.owner)")] + let ownedNames = Set(owned.map { $0.1 }) + var identifiers = Set([manifest.first, manifest.second].compactMap { $0 }) + var failures: [String] = [] + for (identifier, expectedName) in owned { + let record: Simulator + do { + let records = try await simulators(runner: runner) + let named = records.filter { $0.name == expectedName } + guard named.count <= 1 else { + failures.append("Refused ambiguous cleanup because multiple simulators have the exact owned role name.") + continue + } + if let identifier { + guard let found = records.first(where: { $0.identifier == identifier }) else { + if !named.isEmpty { failures.append("The registered fixture UUID is absent but its owned name belongs to another UUID; cleanup was refused.") } + continue + } + guard found.name == expectedName else { + failures.append("Refused cleanup because a registered simulator no longer has its exact owned name.") + continue + } + record = found + } else { + // Creation may have succeeded before its response or registry write failed. + // Reconcile only one exact role-and-owner name; never retry creation. + guard let found = named.first else { continue } + record = found + } + identifiers.insert(record.identifier) + } catch { + failures.append("Could not verify a registered simulator before cleanup: \(bounded(error))") + continue + } + if record.state == "Booted" { + do { _ = try await command(["simctl", "shutdown", record.identifier], timeout: 120, runner: runner) } + catch { failures.append("Owned simulator shutdown failed: \(bounded(error))") } + } + do { _ = try await command(["simctl", "delete", record.identifier], timeout: 120, runner: runner) } + catch { failures.append("Owned simulator deletion failed: \(bounded(error))") } + } + do { + let remaining = try await simulators(runner: runner) + if remaining.contains(where: { identifiers.contains($0.identifier) || ownedNames.contains($0.name) }) { + failures.append("A fresh simulator listing still contains a known fixture UUID or exact owned role name.") + } + } catch { failures.append("Independent cleanup verification failed: \(bounded(error))") } + guard failures.isEmpty else { + throw failure("Fixture cleanup failed; the ownership registry and directory were retained.", cause: nil).appendingDetail(failures.joined(separator: "\n")) + } + do { try FileManager.default.removeItem(at: directory) } + catch { throw failure("Simulators were removed, but the owned fixture directory could not be removed.", cause: error) } + print("[inventory fixtures] Fresh discovery verified all known fixture UUIDs and both exact owned role names absent; the owned fixture directory was removed.") + } + + static func create(name: String, template: Simulator, runner: ProcessCommandRunner) async throws -> String { + let result = try await command(["simctl", "create", name, template.type, template.runtime], timeout: 120, runner: runner) + guard let identifier = UUID(uuidString: result.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw failure("simctl create did not return a valid fixture UUID; inspect its owned name before retrying.", cause: nil) + } + return identifier.uuidString + } + + static func simulators(runner: ProcessCommandRunner) async throws -> [Simulator] { + let json = try JSONValue.parse(try await command(["simctl", "list", "devices", "--json"], timeout: 60, runner: runner).standardOutput) + guard let groups = json["devices"]?.object else { throw failure("simctl returned no device groups.", cause: nil) } + return try groups.keys.sorted().flatMap { runtime -> [Simulator] in + guard let devices = groups[runtime]?.array else { throw failure("simctl returned a non-array device group.", cause: nil) } + return try devices.map { value in + guard let identifier = UUID(uuidString: try string(value, field: "udid")) else { + throw failure("simctl returned an invalid simulator UUID.", cause: nil) + } + return Simulator(identifier: identifier.uuidString, name: try string(value, field: "name"), state: try string(value, field: "state"), + type: try string(value, field: "deviceTypeIdentifier"), runtime: runtime, available: try boolean(value, field: "isAvailable")) + } + } + } + + static func fixture(bundle: String, name: String, directory: URL, runner: ProcessCommandRunner) async throws -> URL { + let app = directory.appendingPathComponent("\(bundle).app", isDirectory: true) + try SecureFileIO.createNewPrivateDirectory(at: app) + let source = directory.appendingPathComponent("\(bundle).swift") + try SecureFileIO.writeNewFile(Data("import Foundation\nwhile true { sleep(1) }\n".utf8), to: source, mode: 0o600) + #if arch(arm64) + let triple = "arm64-apple-ios17.0-simulator" + #else + let triple = "x86_64-apple-ios17.0-simulator" + #endif + _ = try await command(["--sdk", "iphonesimulator", "swiftc", "-target", triple, "-o", app.appendingPathComponent("Fixture").path, source.path], timeout: 300, runner: runner) + let info: PlistValue = ["CFBundleIdentifier": .string(bundle), "CFBundleExecutable": "Fixture", "CFBundleName": .string(name), + "CFBundleDisplayName": .string(name), "CFBundlePackageType": "APPL", "CFBundleVersion": "1", + "CFBundleShortVersionString": "1.0", "MinimumOSVersion": "17.0", "CFBundleSupportedPlatforms": ["iPhoneSimulator"], "UIDeviceFamily": [1, 2]] + try SecureFileIO.writeNewFile(info.encoded(format: .xml), to: app.appendingPathComponent("Info.plist"), mode: 0o600) + let request = CommandRequest(executable: try AppleTool.codesign.locate(), arguments: ["--sign", "-", app.path], timeout: 60, displayName: "sign inventory fixture") + let result = try await runner.run(request) + guard result.succeeded else { throw failure("Signing the inventory fixture failed.", cause: nil).appendingDetail(String(result.technicalSummary.prefix(1536))) } + return app + } + + static func save(_ manifest: Manifest, directory: URL) throws { + try SecureFileIO.writeAtomically(JSONEncoder().encode(manifest), to: directory.appendingPathComponent("manifest.json"), mode: 0o600) + } + + /// If persistence fails after allocation, remove that exact new simulator immediately; + /// the earlier registry continues to own any successfully registered resources. + static func register(_ manifest: Manifest, identifier: String, name: String, directory: URL, runner: ProcessCommandRunner) async throws { + do { try save(manifest, directory: directory) } + catch { + let registrationError = error + do { + if let record = try await simulators(runner: runner).first(where: { $0.identifier == identifier }) { + guard record.name == name else { throw failure("The newly allocated simulator's name changed; removal was refused.", cause: nil) } + _ = try await command(["simctl", "delete", identifier], timeout: 120, runner: runner) + } + guard try await !simulators(runner: runner).contains(where: { $0.identifier == identifier }) else { + throw failure("The unregistered simulator remains after deletion.", cause: nil) + } + } catch { + throw failure("Fixture registration and immediate cleanup both failed; retain the earlier registry and inspect \(name) (\(identifier)).", cause: registrationError) + .appendingDetail(bounded(error)) + } + throw failure("Fixture registration failed; its newly allocated simulator was removed and the earlier registry retained.", cause: registrationError) + } + } + + static func load(directory: URL) throws -> Manifest { + var metadata = stat() + guard lstat(directory.path, &metadata) == 0, metadata.st_mode & S_IFMT == S_IFDIR, + metadata.st_uid == getuid(), metadata.st_mode & 0o777 == 0o700 else { + throw failure("Cleanup requires the original owner-only fixture directory, without symbolic links.", cause: nil) + } + let path = directory.appendingPathComponent("manifest.json").path + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { throw failure("The fixture ownership registry is missing or unreadable; no simulator was changed.", cause: nil) } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + let data: Data + do { + guard fstat(descriptor, &metadata) == 0, metadata.st_mode & S_IFMT == S_IFREG, + metadata.st_uid == getuid(), metadata.st_mode & 0o777 == 0o600, metadata.st_size > 0, metadata.st_size <= 4096 else { + throw failure("The fixture ownership registry must be a bounded owner-only regular file.", cause: nil) + } + data = try handle.read(upToCount: 4097) ?? Data() + try handle.close() + } catch { + do { try handle.close() } + catch let closeError { throw failure("Reading and closing the fixture registry failed.", cause: error).appendingDetail(bounded(closeError)) } + throw failure("The fixture ownership registry could not be validated; no simulator was changed.", cause: error) + } + guard data.count <= 4096, let object = try JSONValue.parse(data).object, + Set(object.keys) == Set(["first", "second", "owner"]) else { + throw failure("The fixture ownership registry has an invalid schema.", cause: nil) + } + let manifest = try JSONDecoder().decode(Manifest.self, from: data) + guard UUID(uuidString: manifest.owner)?.uuidString == manifest.owner, + [manifest.first, manifest.second].compactMap({ $0 }).allSatisfy({ UUID(uuidString: $0)?.uuidString == $0 }), + manifest.first == nil || manifest.first != manifest.second, + manifest.second == nil || manifest.first != nil else { + throw failure("The fixture ownership registry contains invalid, duplicate, or out-of-order UUIDs.", cause: nil) + } + return manifest + } + + static func command(_ arguments: [String], timeout: TimeInterval, runner: ProcessCommandRunner) async throws -> CommandResult { + let request = CommandRequest(executable: try AppleTool.xcrun.locate(), arguments: arguments, timeout: timeout, displayName: "inventory fixture tool") + let result = try await runner.run(request) + guard result.succeeded else { throw failure("An inventory fixture tool failed.", cause: nil).appendingDetail(String(result.technicalSummary.prefix(1536))) } + return result + } + + static func string(_ value: JSONValue, field: String) throws -> String { + guard case .string(let text) = value[field], !text.isEmpty, text.utf8.count <= 512 else { + throw failure("simctl returned an invalid \(field) string.", cause: nil) + } + return text + } + + static func boolean(_ value: JSONValue, field: String) throws -> Bool { + guard case .bool(let result) = value[field] else { throw failure("simctl returned an invalid \(field) boolean.", cause: nil) } + return result + } + + static func bounded(_ error: Error) -> String { String(String(describing: error).prefix(1536)) } + + static func failure(_ message: String, cause: Error?) -> ToolkitError { + ToolkitError(.commandFailed, message: message, recovery: "Inspect the hosted fixture step and retained ownership registry before retrying.", technicalDetail: cause.map(bounded)) + } +}