From 8233a985e7940264e19f29a94e7abf8b688c7ac2 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:14:46 -0700 Subject: [PATCH 1/9] Bind Installed Apps actions to inventory owner Capture the inventory generation and target for Launch and Remove, invalidate stale selections and refreshes, and reject actions whose device is no longer selected. Headless app compilation and Swift package checks pass; native two-simulator acceptance remains pending. --- App/UITests/SmokeUITests.swift | 37 ++++++++ App/iOSDeveloperToolkit/Model/AppModel.swift | 5 +- .../Model/FeatureModels.swift | 92 ++++++++++++++++++- App/iOSDeveloperToolkit/Views/AppsViews.swift | 39 ++++---- 4 files changed, 150 insertions(+), 23 deletions(-) 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/Model/AppModel.swift b/App/iOSDeveloperToolkit/Model/AppModel.swift index 5cfacf9..4780838 100644 --- a/App/iOSDeveloperToolkit/Model/AppModel.swift +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -76,7 +76,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). diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index 860db24..720008e 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, app.selectedTarget == target { 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/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index 06597e8..bb96cd8 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -35,19 +35,22 @@ struct AppsView: View { .foregroundStyle(.orange) } Table(apps.visibleRows, selection: $apps.selection, sortOrder: $apps.sortOrder) { - TableColumn("Name", value: \.name) + 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 { @@ -62,36 +65,38 @@ struct AppsView: View { 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) } } 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) } - } + 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) } } } From ed88356d903e490fd023f42fddb5a0905ab016bb Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:24:27 -0700 Subject: [PATCH 2/9] Keep same-device inventory results after enrichment Selection generation already rejects obsolete device results. Retain a successful result if discovery changes metadata for the same selected device while the refresh is in flight. Strict Swift package checks and headless app compilation pass; native two-simulator UI acceptance remains pending. --- App/iOSDeveloperToolkit/Model/FeatureModels.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index 720008e..f6cb720 100644 --- a/App/iOSDeveloperToolkit/Model/FeatureModels.swift +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -125,7 +125,7 @@ final class AppsModel { ], "Demo data") } } - if let result, refreshID == generation, app.selectedTarget == target { + if let result, refreshID == generation { rows = result.rows source = result.source loadedFor = target From 859ac5f3e6b9a78d85c86335716947ac6f5a5568 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:09:04 -0700 Subject: [PATCH 3/9] Expose stable simulator target identifiers --- App/iOSDeveloperToolkit/Views/ContentView.swift | 1 + Tests/ToolkitCoreTests/CommandRunnerTests.swift | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/App/iOSDeveloperToolkit/Views/ContentView.swift b/App/iOSDeveloperToolkit/Views/ContentView.swift index 903ad9c..018d4b4 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 { diff --git a/Tests/ToolkitCoreTests/CommandRunnerTests.swift b/Tests/ToolkitCoreTests/CommandRunnerTests.swift index c0506bf..8f18776 100644 --- a/Tests/ToolkitCoreTests/CommandRunnerTests.swift +++ b/Tests/ToolkitCoreTests/CommandRunnerTests.swift @@ -102,9 +102,10 @@ struct CommandRunnerTests { // 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. 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 "$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], From 1db60183e43ab09cdaca0fbc9b0ad318d9e93172 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:14:01 -0700 Subject: [PATCH 4/9] Exercise inventory ownership with hosted simulator UI acceptance --- .github/workflows/ci.yml | 26 ++ App/UITests/SimulatorInventoryUITests.swift | 275 ++++++++++++++++++ .../Components/Components.swift | 2 + App/iOSDeveloperToolkit/Model/AppModel.swift | 50 +++- .../Model/InventoryAcceptance.swift | 122 ++++++++ App/iOSDeveloperToolkit/Views/AppsViews.swift | 28 +- iOSDeveloperToolkit.xcodeproj/project.pbxproj | 34 ++- project.yml | 4 + 8 files changed, 532 insertions(+), 9 deletions(-) create mode 100644 App/UITests/SimulatorInventoryUITests.swift create mode 100644 App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2ee08..f95aabf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,30 @@ jobs: -configuration Debug -destination 'platform=macOS' \ -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \ test-without-building + - 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: Render every page at the default and minimum window sizes run: | scripts/check-layout.sh \ @@ -98,6 +122,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..65c0420 --- /dev/null +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -0,0 +1,275 @@ +import DeviceKit +import Foundation +import ToolkitCore +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 var ownedSimulators: [String] = [] + private var deletedSimulators: Set = [] + private var fixtureDirectory: URL? + private let runner = ProcessCommandRunner() + 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() + let client = SimulatorClient(runner: runner) + var cleanupFailures: [String] = [] + for identifier in ownedSimulators.reversed() where !deletedSimulators.contains(identifier) { + do { + let records = try await client.list() + if let record = records.first(where: { $0.udid == identifier }), record.state == .booted { + try await client.shutdown(record.device.target) + } + } catch { + cleanupFailures.append("Could not shut down an owned inventory simulator: \(error)") + } + do { + _ = try await command(["simctl", "delete", identifier], timeout: 120) + } catch { + cleanupFailures.append("Could not delete an owned inventory simulator: \(error)") + } + } + if !ownedSimulators.isEmpty { + do { + let remaining = try await client.list() + if remaining.contains(where: { ownedSimulators.contains($0.udid) }) { + cleanupFailures.append("Both owned simulators must be absent after cleanup") + } + } catch { + cleanupFailures.append("Could not independently verify simulator cleanup: \(error)") + } + } + if let fixtureDirectory { + do { try FileManager.default.removeItem(at: fixtureDirectory) } + catch { cleanupFailures.append("Could not remove the owned fixture directory: \(error)") } + } + XCTAssertTrue(cleanupFailures.isEmpty, cleanupFailures.joined(separator: "\n")) + } + + func testInventoryOwnershipAcrossTwoSimulatorsAndDeliveredLateResult() async throws { + let client = SimulatorClient(runner: runner) + let records = try await client.list() + let template = try XCTUnwrap(records.first { $0.isAvailable && $0.device.family == .iPhone }, + "The runner needs an available iPhone simulator runtime") + let type = try XCTUnwrap(template.deviceTypeIdentifier) + let a = try await createSimulator(name: "IDT Inventory A", type: type, runtime: template.runtimeIdentifier) + let b = try await createSimulator(name: "IDT Inventory B", type: type, runtime: template.runtimeIdentifier) + let created = try await client.list() + let targetA = try XCTUnwrap(created.first { $0.udid == a }).device.target + let targetB = try XCTUnwrap(created.first { $0.udid == b }).device.target + try await client.boot(targetA) + try await client.boot(targetB) + + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "inventory-ui") + fixtureDirectory = directory + let fixtureA = try await buildFixture(bundle: aOnly, name: "000 Inventory A", in: directory) + let fixtureB = try await buildFixture(bundle: bOnly, name: "000 Inventory B", in: directory) + let fixtureShared = try await buildFixture(bundle: shared, name: "001 Inventory Shared", in: directory) + try await client.install(appAt: fixtureA, on: targetA) + try await client.install(appAt: fixtureShared, on: targetA) + try await client.install(appAt: fixtureB, on: targetB) + try await client.install(appAt: fixtureShared, on: targetB) + + let application = XCUIApplication() + app = application + application.launchArguments = ["-ui-testing", "YES", "-demo-mode", "NO", + "-inventory-acceptance-a", a, "-inventory-acceptance-b", b, + "-workspace", "apps", "-ApplePersistenceIgnoreState", "YES"] + application.launchEnvironment["IDT_RESTORE_HELPERS"] = "" + application.launch() + XCTAssertTrue(application.wait(for: .runningForeground, timeout: 10)) + XCTAssertFalse(element("demo-banner").exists) + + selectSimulator(a) + loadInventory(containing: aOnly) + element("installed-app-\(aOnly)").click() + 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) + element("installed-app-\(aOnly)").click() + 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) + element("installed-app-\(shared)").click() + wait(element("apps-remove"), predicate: "exists == true AND enabled == true", timeout: 10) + element("apps-remove").click() + confirmRemoval(on: targetB) + wait(element("installed-app-\(shared)"), predicate: "exists == false", timeout: 120) + wait(element("running-operations"), predicate: "exists == false", timeout: 120) + let appsA = try await client.apps(targetA) + let appsB = try await client.apps(targetB) + XCTAssertTrue(appsA.contains { $0.bundleIdentifier == shared }, "A must retain the shared fixture") + XCTAssertTrue(appsA.contains { $0.bundleIdentifier == aOnly }) + XCTAssertFalse(appsB.contains { $0.bundleIdentifier == shared }, "The confirmed removal must affect B") + XCTAssertTrue(appsB.contains { $0.bundleIdentifier == bOnly }) + + // 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: "label == 'armed'", timeout: 10) + element("load-apps").click() + wait(element("inventory-gate-state"), predicate: "label == 'held'", timeout: 120) + selectSimulator(b) + loadInventory(containing: bOnly) + element("installed-app-\(bOnly)").click() + wait(element("apps-launch"), predicate: "exists == true AND enabled == true", timeout: 10) + let currentSource = element("apps-inventory-source").label + element("inventory-gate-release").click() + wait(element("inventory-gate-state"), predicate: "label == '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").label, 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) + element("installed-app-\(shared)").click() + element("apps-remove").click() + wait(element("apps-confirmation"), predicate: "exists == true", timeout: 10) + XCTAssertFalse(element("confirm-button").isEnabled) + try await client.shutdown(targetA) + _ = try await command(["simctl", "delete", a], timeout: 120) + deletedSimulators.insert(a) + wait(element("apps-confirmation"), predicate: "exists == false", timeout: 180) + selectSimulator(b) + let finalBApps = try await client.apps(targetB) + XCTAssertTrue(finalBApps.contains { $0.bundleIdentifier == bOnly }) + XCTAssertFalse(finalBApps.contains { $0.bundleIdentifier == shared }) + 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) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: timeout), .completed, + "Expected \(element.identifier): \(predicate)") + } + + 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 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 target: DeviceTarget) { + wait(element("apps-confirmation"), predicate: "exists == true", timeout: 10) + let field = element("confirmation-field") + field.click() + field.typeText("IRREVERSIBLE \(target.confirmationSuffix)") + element("confirmation-backup-acknowledgement").click() + wait(element("confirm-button"), predicate: "enabled == true", timeout: 10) + element("confirm-button").click() + wait(element("apps-confirmation"), predicate: "exists == false", timeout: 10) + } + + private func createSimulator(name: String, type: String, runtime: String) async throws -> String { + let result = try await command(["simctl", "create", "\(name) \(UUID().uuidString)", type, runtime], timeout: 120) + let identifier = result.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let uuid = UUID(uuidString: identifier) else { + throw ToolkitError(.protocolViolation, message: "simctl create returned no valid simulator identifier.", + recovery: nil, technicalDetail: nil) + } + ownedSimulators.append(uuid.uuidString) + return uuid.uuidString + } + + private func buildFixture(bundle: String, name: String, in directory: URL) async throws -> URL { + let app = directory.appendingPathComponent("\(bundle).app", isDirectory: true) + try FileManager.default.createDirectory(at: app, withIntermediateDirectories: false) + let source = directory.appendingPathComponent("\(bundle).swift") + try Data("import Foundation\nwhile true { sleep(1) }\n".utf8).write(to: source) + #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) + 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 info.encoded(format: .xml).write(to: app.appendingPathComponent("Info.plist")) + let sign = CommandRequest(executable: try AppleTool.codesign.locate(), arguments: ["--sign", "-", app.path], + timeout: 60, displayName: "sign inventory fixture") + let signed = try await runner.run(sign) + guard signed.succeeded else { + throw ToolkitError(.commandFailed, message: "Could not sign the inventory fixture.", + recovery: nil, technicalDetail: signed.technicalSummary) + } + return app + } + + private func command(_ arguments: [String], timeout: TimeInterval) async throws -> CommandResult { + let request = CommandRequest(executable: try AppleTool.xcrun.locate(), arguments: arguments, + timeout: timeout, displayName: "inventory acceptance tool") + let result = try await runner.run(request) + guard result.succeeded else { + throw ToolkitError(.commandFailed, message: "An inventory acceptance tool failed.", + recovery: nil, technicalDetail: result.technicalSummary) + } + return result + } +} 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 4780838..ed98970 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. @@ -133,11 +136,40 @@ 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") + let acceptanceError: ToolkitError? +#if DEBUG + do { + if let targets = try InventoryAcceptanceTargets.parse(arguments: arguments, uiTesting: uiTesting, demoMode: isDemoMode) { + let acceptance = InventoryAcceptance(targets: targets) + inventoryAcceptance = acceptance + runner = InventoryAcceptanceRunner(base: ProcessCommandRunner(), 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() @@ -153,6 +185,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 @@ -211,7 +244,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/InventoryAcceptance.swift b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift new file mode 100644 index 0000000..99fb412 --- /dev/null +++ b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift @@ -0,0 +1,122 @@ +#if DEBUG +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 + + 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" } + guard !firstFlags.isEmpty || !secondFlags.isEmpty else { return nil } + guard uiTesting, !demoMode, firstFlags.count == 1, secondFlags.count == 1, + let firstIndex = firstFlags.first, arguments.indices.contains(firstIndex + 1), + let secondIndex = secondFlags.first, arguments.indices.contains(secondIndex + 1), + let first = UUID(uuidString: arguments[firstIndex + 1]), + let second = UUID(uuidString: arguments[secondIndex + 1]), first != second else { + throw ToolkitError(.invalidInput, message: "The inventory acceptance launch requires two distinct simulator UUIDs.", recovery: "Use a Debug build with -ui-testing YES, -demo-mode NO and one value for each inventory acceptance target.", technicalDetail: nil) + } + return InventoryAcceptanceTargets(first: first.uuidString, second: second.uuidString) + } + + func contains(_ identifier: String) -> Bool { + let normalized = identifier.uppercased() + return normalized == first || normalized == second + } +} + +/// 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 var held: (result: CommandResult, continuation: CheckedContinuation)? + private var deadline: Task? + + init(targets: InventoryAcceptanceTargets) { + self.targets = targets + } + + 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 + } + } + + 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 bb96cd8..2c483d3 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -11,10 +11,33 @@ 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") + } + } +#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,6 +56,7 @@ 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) { Text($0.name).accessibilityIdentifier("installed-app-\($0.bundleIdentifier)") } @@ -62,6 +86,7 @@ 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) } @@ -78,6 +103,7 @@ struct AppsView: View { .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) + .accessibilityIdentifier("apps-confirmation") } } diff --git a/iOSDeveloperToolkit.xcodeproj/project.pbxproj b/iOSDeveloperToolkit.xcodeproj/project.pbxproj index 0880dad..5697e04 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,8 @@ 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 */; }; + 41FA4103BF4458C9866B6147 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5185ABEDE9A30B7E73B18179 /* DeviceKit */; }; 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 */; }; @@ -36,6 +39,7 @@ E0A3673F60ACD7FFF3801DFC /* ScreenshotHarness.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10F7407E0C32BF6634D85B5 /* ScreenshotHarness.swift */; }; E374C2EAE2467F2B1D01215E /* ToolkitFeatures in Frameworks */ = {isa = PBXBuildFile; productRef = 529398E755DBC14015ADEA9B /* ToolkitFeatures */; }; E7A37DFF9FF0F68ABE51BD0C /* SecurityAnalysisModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DDC0E48182D2C63DFB57618 /* SecurityAnalysisModel.swift */; }; + F53B4875C2CB1ED5F0D5E146 /* ToolkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 75934517EF439BF6F948A0AB /* ToolkitCore */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -57,6 +61,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 +70,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 +81,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,10 +91,20 @@ 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 */ /* Begin PBXFrameworksBuildPhase section */ + 1AFCF58B1EB784E8AF2121FD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F53B4875C2CB1ED5F0D5E146 /* ToolkitCore in Frameworks */, + 41FA4103BF4458C9866B6147 /* DeviceKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 31B9C83311C44A75CE1F24EC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -110,6 +125,7 @@ A42E01F498D8AE9BAE52EA83 /* DeveloperImageModel.swift */, D34F678B9113D08044E08644 /* FeatureModels.swift */, DA238102E7E6CB9B5DB927EC /* FirmwareModel.swift */, + 644C9380AD3F95F4A9D88B5F /* InventoryAcceptance.swift */, CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */, 585ECC84A09B869053F28246 /* LocationModel.swift */, F10F7407E0C32BF6634D85B5 /* ScreenshotHarness.swift */, @@ -139,6 +155,7 @@ 60C4D44F4E03EAEE6FC8028C /* UITests */ = { isa = PBXGroup; children = ( + F0B1B6EF0D1B0F74DE215798 /* SimulatorInventoryUITests.swift */, 2095C064BD8BE67389318E84 /* SmokeUITests.swift */, ); path = UITests; @@ -190,7 +207,7 @@ 9F007AA80ECE5A10D372536D /* Packages */ = { isa = PBXGroup; children = ( - BF28B9563BB119B8B18C547A /* IOSDeveloperToolkit-Swift */, + 14599347238BF6D17C5DD92D /* IOSDeveloperToolkit-Swift */, ); name = Packages; sourceTree = ""; @@ -232,6 +249,7 @@ buildConfigurationList = 70865C801CBA83232B5FBACD /* Build configuration list for PBXNativeTarget "iOSDeveloperToolkitUITests" */; buildPhases = ( 6ACF5677B377FA5CFAEB5501 /* Sources */, + 1AFCF58B1EB784E8AF2121FD /* Frameworks */, ); buildRules = ( ); @@ -240,6 +258,8 @@ ); name = iOSDeveloperToolkitUITests; packageProductDependencies = ( + 75934517EF439BF6F948A0AB /* ToolkitCore */, + 5185ABEDE9A30B7E73B18179 /* DeviceKit */, ); productName = iOSDeveloperToolkitUITests; productReference = B1FF773D9D443C79240CC926 /* iOSDeveloperToolkitUITests.xctest */; @@ -414,6 +434,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 +454,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3C9616CF740BAB500DD923FA /* SimulatorInventoryUITests.swift in Sources */, 25D059CF99AA7F74E66147F4 /* SmokeUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -760,10 +782,18 @@ isa = XCSwiftPackageProductDependency; productName = DeviceKit; }; + 5185ABEDE9A30B7E73B18179 /* DeviceKit */ = { + isa = XCSwiftPackageProductDependency; + productName = DeviceKit; + }; 529398E755DBC14015ADEA9B /* ToolkitFeatures */ = { isa = XCSwiftPackageProductDependency; productName = ToolkitFeatures; }; + 75934517EF439BF6F948A0AB /* ToolkitCore */ = { + isa = XCSwiftPackageProductDependency; + productName = ToolkitCore; + }; CE5B03128B0835E13440AFA5 /* ToolkitCore */ = { isa = XCSwiftPackageProductDependency; productName = ToolkitCore; diff --git a/project.yml b/project.yml index b4ada7c..de5e226 100644 --- a/project.yml +++ b/project.yml @@ -126,6 +126,10 @@ targets: - path: App/UITests dependencies: - target: iOSDeveloperToolkit + - package: Toolkit + product: ToolkitCore + - package: Toolkit + product: DeviceKit settings: base: PRODUCT_BUNDLE_IDENTIFIER: io.hideouts.iOSDeveloperToolkitUITests From 74656470cb75e4eb1c8b0486e51df7830c53fff1 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:43:55 -0700 Subject: [PATCH 5/9] Keep simulator fixture ownership outside the UI test sandbox --- .github/workflows/ci.yml | 32 ++ App/UITests/SimulatorInventoryUITests.swift | 173 ++++------- App/iOSDeveloperToolkit/Model/AppModel.swift | 7 +- .../Model/InventoryAcceptance.swift | 47 ++- App/iOSDeveloperToolkit/Views/AppsViews.swift | 24 +- iOSDeveloperToolkit.xcodeproj/project.pbxproj | 22 -- project.yml | 4 - scripts/SimulatorInventoryFixtures.swift | 276 ++++++++++++++++++ 8 files changed, 425 insertions(+), 160 deletions(-) create mode 100644 scripts/SimulatorInventoryFixtures.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f95aabf..05e3dd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,35 @@ 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" @@ -111,6 +140,9 @@ jobs: 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 \ diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index 65c0420..1834d46 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -1,6 +1,4 @@ -import DeviceKit import Foundation -import ToolkitCore import XCTest /// Native acceptance on two task-owned simulators. The explicit opt-in keeps ordinary local @@ -8,10 +6,6 @@ import XCTest @MainActor final class SimulatorInventoryUITests: XCTestCase { private var app: XCUIApplication? - private var ownedSimulators: [String] = [] - private var deletedSimulators: Set = [] - private var fixtureDirectory: URL? - private let runner = ProcessCommandRunner() private let aOnly = "io.hideouts.idt.inventory.a" private let bOnly = "io.hideouts.idt.inventory.b" private let shared = "io.hideouts.idt.inventory.shared" @@ -24,68 +18,28 @@ final class SimulatorInventoryUITests: XCTestCase { override func tearDown() async throws { app?.terminate() - let client = SimulatorClient(runner: runner) - var cleanupFailures: [String] = [] - for identifier in ownedSimulators.reversed() where !deletedSimulators.contains(identifier) { - do { - let records = try await client.list() - if let record = records.first(where: { $0.udid == identifier }), record.state == .booted { - try await client.shutdown(record.device.target) - } - } catch { - cleanupFailures.append("Could not shut down an owned inventory simulator: \(error)") - } - do { - _ = try await command(["simctl", "delete", identifier], timeout: 120) - } catch { - cleanupFailures.append("Could not delete an owned inventory simulator: \(error)") - } - } - if !ownedSimulators.isEmpty { - do { - let remaining = try await client.list() - if remaining.contains(where: { ownedSimulators.contains($0.udid) }) { - cleanupFailures.append("Both owned simulators must be absent after cleanup") - } - } catch { - cleanupFailures.append("Could not independently verify simulator cleanup: \(error)") - } - } - if let fixtureDirectory { - do { try FileManager.default.removeItem(at: fixtureDirectory) } - catch { cleanupFailures.append("Could not remove the owned fixture directory: \(error)") } - } - XCTAssertTrue(cleanupFailures.isEmpty, cleanupFailures.joined(separator: "\n")) } - func testInventoryOwnershipAcrossTwoSimulatorsAndDeliveredLateResult() async throws { - let client = SimulatorClient(runner: runner) - let records = try await client.list() - let template = try XCTUnwrap(records.first { $0.isAvailable && $0.device.family == .iPhone }, - "The runner needs an available iPhone simulator runtime") - let type = try XCTUnwrap(template.deviceTypeIdentifier) - let a = try await createSimulator(name: "IDT Inventory A", type: type, runtime: template.runtimeIdentifier) - let b = try await createSimulator(name: "IDT Inventory B", type: type, runtime: template.runtimeIdentifier) - let created = try await client.list() - let targetA = try XCTUnwrap(created.first { $0.udid == a }).device.target - let targetB = try XCTUnwrap(created.first { $0.udid == b }).device.target - try await client.boot(targetA) - try await client.boot(targetB) - - let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "inventory-ui") - fixtureDirectory = directory - let fixtureA = try await buildFixture(bundle: aOnly, name: "000 Inventory A", in: directory) - let fixtureB = try await buildFixture(bundle: bOnly, name: "000 Inventory B", in: directory) - let fixtureShared = try await buildFixture(bundle: shared, name: "001 Inventory Shared", in: directory) - try await client.install(appAt: fixtureA, on: targetA) - try await client.install(appAt: fixtureShared, on: targetA) - try await client.install(appAt: fixtureB, on: targetB) - try await client.install(appAt: fixtureShared, on: targetB) + 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() @@ -113,15 +67,17 @@ final class SimulatorInventoryUITests: XCTestCase { element("installed-app-\(shared)").click() wait(element("apps-remove"), predicate: "exists == true AND enabled == true", timeout: 10) element("apps-remove").click() - confirmRemoval(on: targetB) + confirmRemoval(on: b) wait(element("installed-app-\(shared)"), predicate: "exists == false", timeout: 120) wait(element("running-operations"), predicate: "exists == false", timeout: 120) - let appsA = try await client.apps(targetA) - let appsB = try await client.apps(targetB) - XCTAssertTrue(appsA.contains { $0.bundleIdentifier == shared }, "A must retain the shared fixture") - XCTAssertTrue(appsA.contains { $0.bundleIdentifier == aOnly }) - XCTAssertFalse(appsB.contains { $0.bundleIdentifier == shared }, "The confirmed removal must affect B") - XCTAssertTrue(appsB.contains { $0.bundleIdentifier == bOnly }) + // 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 @@ -156,14 +112,16 @@ final class SimulatorInventoryUITests: XCTestCase { element("apps-remove").click() wait(element("apps-confirmation"), predicate: "exists == true", timeout: 10) XCTAssertFalse(element("confirm-button").isEnabled) - try await client.shutdown(targetA) - _ = try await command(["simctl", "delete", a], timeout: 120) - deletedSimulators.insert(a) + let deleteOwnedA = element("inventory-delete-owned-a") + wait(deleteOwnedA, predicate: "exists == true AND enabled == true", timeout: 10) + deleteOwnedA.click() wait(element("apps-confirmation"), predicate: "exists == false", timeout: 180) selectSimulator(b) - let finalBApps = try await client.apps(targetB) - XCTAssertTrue(finalBApps.contains { $0.bundleIdentifier == bOnly }) - XCTAssertFalse(finalBApps.contains { $0.bundleIdentifier == shared }) + 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") } @@ -174,7 +132,15 @@ final class SimulatorInventoryUITests: XCTestCase { private func wait(_ element: XCUIElement, predicate: String, timeout: TimeInterval) { let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(format: predicate), object: element) - XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: timeout), .completed, + 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, "Expected \(element.identifier): \(predicate)") } @@ -211,65 +177,24 @@ final class SimulatorInventoryUITests: XCTestCase { app?.typeKey(.escape, modifierFlags: []) } - private func confirmRemoval(on target: DeviceTarget) { + private func confirmRemoval(on identifier: String) { wait(element("apps-confirmation"), predicate: "exists == true", timeout: 10) let field = element("confirmation-field") field.click() - field.typeText("IRREVERSIBLE \(target.confirmationSuffix)") + 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("apps-confirmation"), predicate: "exists == false", timeout: 10) } - private func createSimulator(name: String, type: String, runtime: String) async throws -> String { - let result = try await command(["simctl", "create", "\(name) \(UUID().uuidString)", type, runtime], timeout: 120) - let identifier = result.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines) - guard let uuid = UUID(uuidString: identifier) else { - throw ToolkitError(.protocolViolation, message: "simctl create returned no valid simulator identifier.", - recovery: nil, technicalDetail: nil) - } - ownedSimulators.append(uuid.uuidString) - return uuid.uuidString + private struct InventoryFixtures: Decodable { + let first: String + let second: String + let owner: String } - private func buildFixture(bundle: String, name: String, in directory: URL) async throws -> URL { - let app = directory.appendingPathComponent("\(bundle).app", isDirectory: true) - try FileManager.default.createDirectory(at: app, withIntermediateDirectories: false) - let source = directory.appendingPathComponent("\(bundle).swift") - try Data("import Foundation\nwhile true { sleep(1) }\n".utf8).write(to: source) - #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) - 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 info.encoded(format: .xml).write(to: app.appendingPathComponent("Info.plist")) - let sign = CommandRequest(executable: try AppleTool.codesign.locate(), arguments: ["--sign", "-", app.path], - timeout: 60, displayName: "sign inventory fixture") - let signed = try await runner.run(sign) - guard signed.succeeded else { - throw ToolkitError(.commandFailed, message: "Could not sign the inventory fixture.", - recovery: nil, technicalDetail: signed.technicalSummary) - } - return app - } - - private func command(_ arguments: [String], timeout: TimeInterval) async throws -> CommandResult { - let request = CommandRequest(executable: try AppleTool.xcrun.locate(), arguments: arguments, - timeout: timeout, displayName: "inventory acceptance tool") - let result = try await runner.run(request) - guard result.succeeded else { - throw ToolkitError(.commandFailed, message: "An inventory acceptance tool failed.", - recovery: nil, technicalDetail: result.technicalSummary) - } - return result + private struct InventoryFixtureFailure: Error { + let message: String } } diff --git a/App/iOSDeveloperToolkit/Model/AppModel.swift b/App/iOSDeveloperToolkit/Model/AppModel.swift index ed98970..361671a 100644 --- a/App/iOSDeveloperToolkit/Model/AppModel.swift +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -139,14 +139,15 @@ final class AppModel { let isDemoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode")) demoMode = isDemoMode selectedDeviceID = forceDemo ? DemoMode.device.id : UserDefaults.standard.string(forKey: "selectedDeviceID") - let acceptanceRequested = arguments.contains("-inventory-acceptance-a") || arguments.contains("-inventory-acceptance-b") + 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 acceptance = InventoryAcceptance(targets: targets) + let base = ProcessCommandRunner() + let acceptance = InventoryAcceptance(targets: targets, base: base) inventoryAcceptance = acceptance - runner = InventoryAcceptanceRunner(base: ProcessCommandRunner(), acceptance: acceptance) + runner = InventoryAcceptanceRunner(base: base, acceptance: acceptance) } else { inventoryAcceptance = nil runner = ProcessCommandRunner() diff --git a/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift index 99fb412..ab20150 100644 --- a/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift +++ b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift @@ -1,4 +1,5 @@ #if DEBUG +import DeviceKit import Foundation import Observation import ToolkitCore @@ -7,19 +8,23 @@ import ToolkitCore 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" } - guard !firstFlags.isEmpty || !secondFlags.isEmpty else { return nil } - guard uiTesting, !demoMode, firstFlags.count == 1, secondFlags.count == 1, + 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 else { - throw ToolkitError(.invalidInput, message: "The inventory acceptance launch requires two distinct simulator UUIDs.", recovery: "Use a Debug build with -ui-testing YES, -demo-mode NO and one value for each inventory acceptance target.", technicalDetail: nil) + 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) + return InventoryAcceptanceTargets(first: first.uuidString, second: second.uuidString, owner: owner.uuidString) } func contains(_ identifier: String) -> Bool { @@ -41,11 +46,14 @@ final class InventoryAcceptance { let targets: InventoryAcceptanceTargets private(set) var state: State = .idle + private(set) var isDeletingFirst = false + private let base: ProcessCommandRunner private var held: (result: CommandResult, continuation: CheckedContinuation)? private var deadline: Task? - init(targets: InventoryAcceptanceTargets) { + init(targets: InventoryAcceptanceTargets, base: ProcessCommandRunner) { self.targets = targets + self.base = base } func arm() throws(ToolkitError) { @@ -72,6 +80,33 @@ final class InventoryAcceptance { } } + /// 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 } diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index 2c483d3..df22fd8 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -102,8 +102,30 @@ struct AppsView: View { .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) .accessibilityIdentifier("apps-confirmation") +#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 } } diff --git a/iOSDeveloperToolkit.xcodeproj/project.pbxproj b/iOSDeveloperToolkit.xcodeproj/project.pbxproj index 5697e04..4b0909f 100644 --- a/iOSDeveloperToolkit.xcodeproj/project.pbxproj +++ b/iOSDeveloperToolkit.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 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 */; }; - 41FA4103BF4458C9866B6147 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5185ABEDE9A30B7E73B18179 /* DeviceKit */; }; 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 */; }; @@ -39,7 +38,6 @@ E0A3673F60ACD7FFF3801DFC /* ScreenshotHarness.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10F7407E0C32BF6634D85B5 /* ScreenshotHarness.swift */; }; E374C2EAE2467F2B1D01215E /* ToolkitFeatures in Frameworks */ = {isa = PBXBuildFile; productRef = 529398E755DBC14015ADEA9B /* ToolkitFeatures */; }; E7A37DFF9FF0F68ABE51BD0C /* SecurityAnalysisModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DDC0E48182D2C63DFB57618 /* SecurityAnalysisModel.swift */; }; - F53B4875C2CB1ED5F0D5E146 /* ToolkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 75934517EF439BF6F948A0AB /* ToolkitCore */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -96,15 +94,6 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 1AFCF58B1EB784E8AF2121FD /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - F53B4875C2CB1ED5F0D5E146 /* ToolkitCore in Frameworks */, - 41FA4103BF4458C9866B6147 /* DeviceKit in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 31B9C83311C44A75CE1F24EC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -249,7 +238,6 @@ buildConfigurationList = 70865C801CBA83232B5FBACD /* Build configuration list for PBXNativeTarget "iOSDeveloperToolkitUITests" */; buildPhases = ( 6ACF5677B377FA5CFAEB5501 /* Sources */, - 1AFCF58B1EB784E8AF2121FD /* Frameworks */, ); buildRules = ( ); @@ -258,8 +246,6 @@ ); name = iOSDeveloperToolkitUITests; packageProductDependencies = ( - 75934517EF439BF6F948A0AB /* ToolkitCore */, - 5185ABEDE9A30B7E73B18179 /* DeviceKit */, ); productName = iOSDeveloperToolkitUITests; productReference = B1FF773D9D443C79240CC926 /* iOSDeveloperToolkitUITests.xctest */; @@ -782,18 +768,10 @@ isa = XCSwiftPackageProductDependency; productName = DeviceKit; }; - 5185ABEDE9A30B7E73B18179 /* DeviceKit */ = { - isa = XCSwiftPackageProductDependency; - productName = DeviceKit; - }; 529398E755DBC14015ADEA9B /* ToolkitFeatures */ = { isa = XCSwiftPackageProductDependency; productName = ToolkitFeatures; }; - 75934517EF439BF6F948A0AB /* ToolkitCore */ = { - isa = XCSwiftPackageProductDependency; - productName = ToolkitCore; - }; CE5B03128B0835E13440AFA5 /* ToolkitCore */ = { isa = XCSwiftPackageProductDependency; productName = ToolkitCore; diff --git a/project.yml b/project.yml index de5e226..b4ada7c 100644 --- a/project.yml +++ b/project.yml @@ -126,10 +126,6 @@ targets: - path: App/UITests dependencies: - target: iOSDeveloperToolkit - - package: Toolkit - product: ToolkitCore - - package: Toolkit - product: DeviceKit settings: base: PRODUCT_BUNDLE_IDENTIFIER: io.hideouts.iOSDeveloperToolkitUITests 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)) + } +} From e244f95e7e50f6d4d12abb6dd2eac1cb32df5f83 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:29:01 -0700 Subject: [PATCH 6/9] Preserve simulator selection during cancelled discovery refreshes --- App/UITests/SimulatorInventoryUITests.swift | 13 +++++++------ App/iOSDeveloperToolkit/Views/ContentView.swift | 4 +++- Sources/DeviceKit/Discovery/DeviceDiscovery.swift | 8 ++++++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index 1834d46..3a6fba9 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -85,23 +85,24 @@ final class SimulatorInventoryUITests: XCTestCase { selectSimulator(a) loadInventory(containing: aOnly) element("inventory-gate-arm").click() - wait(element("inventory-gate-state"), predicate: "label == 'armed'", timeout: 10) + wait(element("inventory-gate-state"), predicate: "value == 'armed'", timeout: 10) element("load-apps").click() - wait(element("inventory-gate-state"), predicate: "label == 'held'", timeout: 120) + wait(element("inventory-gate-state"), predicate: "value == 'held'", timeout: 120) selectSimulator(b) loadInventory(containing: bOnly) element("installed-app-\(bOnly)").click() wait(element("apps-launch"), predicate: "exists == true AND enabled == true", timeout: 10) - let currentSource = element("apps-inventory-source").label + 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: "label == 'delivered'", timeout: 10) + 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").label, currentSource) + 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. @@ -141,7 +142,7 @@ final class SimulatorInventoryUITests: XCTestCase { add(attachment) } XCTAssertEqual(result, .completed, - "Expected \(element.identifier): \(predicate)") + "The native accessibility condition did not settle: \(predicate). Inspect the attached hierarchy and preceding element query.") } private func selectSimulator(_ identifier: String) { diff --git a/App/iOSDeveloperToolkit/Views/ContentView.swift b/App/iOSDeveloperToolkit/Views/ContentView.swift index 018d4b4..c3da0b4 100644 --- a/App/iOSDeveloperToolkit/Views/ContentView.swift +++ b/App/iOSDeveloperToolkit/Views/ContentView.swift @@ -303,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) @@ -312,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) From e25989459644215a8763278d85a61dfbe13d0fd8 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:00:04 -0700 Subject: [PATCH 7/9] Preserve native confirmation control identifiers --- App/UITests/SimulatorInventoryUITests.swift | 11 +++++++---- App/iOSDeveloperToolkit/Views/AppsViews.swift | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index 3a6fba9..6720055 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -111,12 +111,13 @@ final class SimulatorInventoryUITests: XCTestCase { loadInventory(containing: aOnly) element("installed-app-\(shared)").click() element("apps-remove").click() - wait(element("apps-confirmation"), predicate: "exists == true", timeout: 10) + 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("apps-confirmation"), predicate: "exists == false", timeout: 180) + wait(element("confirm-button"), predicate: "exists == false", timeout: 180) selectSimulator(b) loadInventory(containing: bOnly) wait(element("running-operations"), predicate: "exists == false", timeout: 120) @@ -179,14 +180,16 @@ final class SimulatorInventoryUITests: XCTestCase { } private func confirmRemoval(on identifier: String) { - wait(element("apps-confirmation"), predicate: "exists == true", timeout: 10) + 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("apps-confirmation"), predicate: "exists == false", timeout: 10) + wait(element("confirm-button"), predicate: "exists == false", timeout: 10) } private struct InventoryFixtures: Decodable { diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index df22fd8..a1739ae 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -103,7 +103,6 @@ struct AppsView: View { .onChange(of: model.selectedTarget) { _, _ in confirmation = nil } .sheet(item: $confirmation) { pending in let confirmationSheet = ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) - .accessibilityIdentifier("apps-confirmation") #if DEBUG if let acceptance = model.inventoryAcceptance, let target = pending.target, target.kind == .simulator, target.udid.uppercased() == acceptance.targets.first { From 4f1653cbbfa9125578517e84c84328ccb3fa0d67 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:40:13 -0700 Subject: [PATCH 8/9] Select native inventory rows before asserting action state --- App/UITests/SimulatorInventoryUITests.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index 6720055..58c0d00 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -48,14 +48,14 @@ final class SimulatorInventoryUITests: XCTestCase { selectSimulator(a) loadInventory(containing: aOnly) - element("installed-app-\(aOnly)").click() + 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) - element("installed-app-\(aOnly)").click() + 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)")) @@ -64,7 +64,7 @@ final class SimulatorInventoryUITests: XCTestCase { loadInventory(containing: bOnly) XCTAssertFalse(element("installed-app-\(aOnly)").exists) XCTAssertFalse(element("apps-stale-inventory").exists) - element("installed-app-\(shared)").click() + selectApp(shared) wait(element("apps-remove"), predicate: "exists == true AND enabled == true", timeout: 10) element("apps-remove").click() confirmRemoval(on: b) @@ -90,7 +90,7 @@ final class SimulatorInventoryUITests: XCTestCase { wait(element("inventory-gate-state"), predicate: "value == 'held'", timeout: 120) selectSimulator(b) loadInventory(containing: bOnly) - element("installed-app-\(bOnly)").click() + 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") @@ -109,7 +109,7 @@ final class SimulatorInventoryUITests: XCTestCase { // Real loss of owned A exercises the observed target-change dismissal path instead. selectSimulator(a) loadInventory(containing: aOnly) - element("installed-app-\(shared)").click() + selectApp(shared) element("apps-remove").click() wait(element("confirm-button"), predicate: "exists == true", timeout: 10) XCTAssertEqual(application.sheets.count, 1) @@ -156,6 +156,15 @@ final class SimulatorInventoryUITests: XCTestCase { wait(target, predicate: "exists == false", timeout: 10) } + private func selectApp(_ bundle: String) { + let rows = element("installed-apps-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 AND hittable == true", timeout: 10) + row.click() + wait(row, predicate: "selected == true", timeout: 10) + } + private func loadInventory(containing bundle: String) { let load = element("load-apps") wait(load, predicate: "exists == true AND enabled == true", timeout: 10) From 97d526958b5a59944d63be5ee2d4cc2870a31b4c Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:09:12 -0700 Subject: [PATCH 9/9] Wait for simulator discovery before opening its native menu --- App/UITests/SimulatorInventoryUITests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index 58c0d00..d5450bf 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -46,6 +46,14 @@ final class SimulatorInventoryUITests: XCTestCase { 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) selectApp(aOnly)