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 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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 09/16] 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) From a2ee6cba54aebeca6a0916dde8fc7eea6a070383 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:14:20 -0800 Subject: [PATCH 10/16] Trace native inventory selection and click the identified cell --- App/UITests/SimulatorInventoryUITests.swift | 31 ++++++++++++++----- .../Model/InventoryAcceptance.swift | 8 +++++ App/iOSDeveloperToolkit/Views/AppsViews.swift | 20 +++++++++++- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index d5450bf..9439c9b 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -56,14 +56,14 @@ final class SimulatorInventoryUITests: XCTestCase { selectSimulator(a) loadInventory(containing: aOnly) - selectApp(aOnly) + try selectApp(aOnly) wait(element("apps-launch"), predicate: "exists == true AND enabled == true", timeout: 10) selectSimulator(b) wait(element("apps-launch"), predicate: "exists == false", timeout: 10) XCTAssertFalse(element("apps-remove").exists, "Changing target must clear row selection") wait(element("apps-stale-inventory"), predicate: "exists == true", timeout: 10) - selectApp(aOnly) + try selectApp(aOnly) wait(element("apps-launch"), predicate: "exists == true AND enabled == false", timeout: 10) XCTAssertFalse(element("apps-remove").isEnabled) assertStaleContextActionsAreDisabled(row: element("installed-app-\(aOnly)")) @@ -72,7 +72,7 @@ final class SimulatorInventoryUITests: XCTestCase { loadInventory(containing: bOnly) XCTAssertFalse(element("installed-app-\(aOnly)").exists) XCTAssertFalse(element("apps-stale-inventory").exists) - selectApp(shared) + try selectApp(shared) wait(element("apps-remove"), predicate: "exists == true AND enabled == true", timeout: 10) element("apps-remove").click() confirmRemoval(on: b) @@ -98,7 +98,7 @@ final class SimulatorInventoryUITests: XCTestCase { wait(element("inventory-gate-state"), predicate: "value == 'held'", timeout: 120) selectSimulator(b) loadInventory(containing: bOnly) - selectApp(bOnly) + try selectApp(bOnly) wait(element("apps-launch"), predicate: "exists == true AND enabled == true", timeout: 10) let currentSource = try XCTUnwrap(element("apps-inventory-source").value as? String) XCTAssertFalse(currentSource.isEmpty, "The inventory provenance must expose a readable native value") @@ -117,7 +117,7 @@ final class SimulatorInventoryUITests: XCTestCase { // Real loss of owned A exercises the observed target-change dismissal path instead. selectSimulator(a) loadInventory(containing: aOnly) - selectApp(shared) + try selectApp(shared) element("apps-remove").click() wait(element("confirm-button"), predicate: "exists == true", timeout: 10) XCTAssertEqual(application.sheets.count, 1) @@ -164,15 +164,32 @@ final class SimulatorInventoryUITests: XCTestCase { wait(target, predicate: "exists == false", timeout: 10) } - private func selectApp(_ bundle: String) { + private func selectApp(_ bundle: String) throws { 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() + let name = row.descendants(matching: .any)["installed-app-\(bundle)"] + wait(name, predicate: "exists == true AND hittable == true", timeout: 10) + try attachSelectionInput(phase: "before click", row: row) + // A native outline row's computed hittable point can lie beyond its cells. + // Send one real click to the center of the cell identified by this bundle ID. + name.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() + try attachSelectionInput(phase: "after click", row: row) wait(row, predicate: "selected == true", timeout: 10) } + private func attachSelectionInput(phase: String, row: XCUIElement) throws { + let input = element("inventory-selection-input") + wait(input, predicate: "exists == true", timeout: 10) + let trace = try XCTUnwrap(input.value as? String, "Selection input diagnostic must expose a native value") + XCTAssertFalse(trace.isEmpty) + let attachment = XCTAttachment(string: "\(phase): selected=\(row.isSelected); \(trace)") + attachment.name = "inventory-selection-\(phase)" + attachment.lifetime = .keepAlways + add(attachment) + } + private func loadInventory(containing bundle: String) { let load = element("load-apps") wait(load, predicate: "exists == true AND enabled == true", timeout: 10) diff --git a/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift index ab20150..1b47d80 100644 --- a/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift +++ b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift @@ -47,6 +47,7 @@ final class InventoryAcceptance { let targets: InventoryAcceptanceTargets private(set) var state: State = .idle private(set) var isDeletingFirst = false + private(set) var selectionInputTrace: [String] = [] private let base: ProcessCommandRunner private var held: (result: CommandResult, continuation: CheckedContinuation)? private var deadline: Task? @@ -56,6 +57,13 @@ final class InventoryAcceptance { self.base = base } + /// Observes native table binding writes without changing the selection or action policy. + func recordSelectionInput(previous: Set, next: Set) { + let before = previous.sorted().joined(separator: ",").prefix(256) + let after = next.sorted().joined(separator: ",").prefix(256) + selectionInputTrace = Array((selectionInputTrace + ["[\(before)] → [\(after)]"]).suffix(8)) + } + func 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) diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index a1739ae..b3c71a6 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -26,6 +26,11 @@ struct AppsView: View { .accessibilityIdentifier("inventory-gate-release") Text(acceptance.state.rawValue) .accessibilityIdentifier("inventory-gate-state") + Text("Selection input") + .font(.caption) + .lineLimit(1) + .accessibilityIdentifier("inventory-selection-input") + .accessibilityValue(Text(verbatim: "writes: \(acceptance.selectionInputTrace.joined(separator: "; ")); current: \(apps.selection.sorted().joined(separator: ",").prefix(256)); loading: \(apps.isLoading)")) } } #endif @@ -58,7 +63,7 @@ struct AppsView: View { .foregroundStyle(.orange) .accessibilityIdentifier("apps-stale-inventory") } - Table(apps.visibleRows, selection: $apps.selection, sortOrder: $apps.sortOrder) { + Table(apps.visibleRows, selection: appSelection, sortOrder: $apps.sortOrder) { TableColumn("Name", value: \.name) { Text($0.name).accessibilityIdentifier("installed-app-\($0.bundleIdentifier)") } TableColumn("Bundle ID", value: \.bundleIdentifier) { Text($0.bundleIdentifier).font(.callout.monospaced()) } TableColumn("Version", value: \.version) { Text($0.build.isEmpty ? $0.version : "\($0.version) (\($0.build))") } @@ -128,6 +133,19 @@ struct AppsView: View { } } + private var appSelection: Binding> { + @Bindable var apps = model.apps +#if DEBUG + if let acceptance = model.inventoryAcceptance { + return Binding(get: { apps.selection }, set: { next in + acceptance.recordSelectionInput(previous: apps.selection, next: next) + apps.selection = next + }) + } +#endif + return $apps.selection + } + private func confirmRemove(_ row: AppRow, _ device: Device) { do { let request = try model.apps.prepareAction(for: row, device: device, selectedTarget: model.selectedTarget) From a9154e6399ccd1b44fa78f54c18c717488ad43b8 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:23:39 -0800 Subject: [PATCH 11/16] Make owned cancellation fixture child cleanup deterministic --- Tests/ToolkitCoreTests/CommandRunnerTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/ToolkitCoreTests/CommandRunnerTests.swift b/Tests/ToolkitCoreTests/CommandRunnerTests.swift index 8f18776..4f6fe6a 100644 --- a/Tests/ToolkitCoreTests/CommandRunnerTests.swift +++ b/Tests/ToolkitCoreTests/CommandRunnerTests.swift @@ -101,8 +101,9 @@ struct CommandRunnerTests { defer { close(descriptor) } // The child acknowledges SIGTERM but waits for a test-controlled release before exit. // A FIFO makes each assertion depend on a real process state rather than a delay. + // Only the owned shell's TERM handling is under test; its idle child is fixture cleanup. let script = """ - trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; printf "after release\\n"; exit 0' TERM + trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; kill -KILL "$child" 2>/dev/null; wait "$child" 2>/dev/null; printf "after release\\n"; exit 0' TERM sleep 30 & child=$! printf 'ready:%s\\n' "$$" wait "$child" From 284bc0aa3a0c8c9a09d407c78f6e67b93e152592 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:05:32 -0800 Subject: [PATCH 12/16] Observe native window readiness before inventory selection --- App/UITests/SimulatorInventoryUITests.swift | 10 ++++++ .../Model/InventoryAcceptance.swift | 31 +++++++++++++++++++ App/iOSDeveloperToolkit/Views/AppsViews.swift | 21 ++++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index 9439c9b..a2af659 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -43,6 +43,7 @@ final class SimulatorInventoryUITests: XCTestCase { "-workspace", "apps", "-ApplePersistenceIgnoreState", "YES"] application.launchEnvironment["IDT_RESTORE_HELPERS"] = "" application.launch() + application.activate() XCTAssertTrue(application.wait(for: .runningForeground, timeout: 10)) XCTAssertFalse(element("demo-banner").exists) @@ -171,6 +172,8 @@ final class SimulatorInventoryUITests: XCTestCase { wait(row, predicate: "exists == true AND hittable == true", timeout: 10) let name = row.descendants(matching: .any)["installed-app-\(bundle)"] wait(name, predicate: "exists == true AND hittable == true", timeout: 10) + try attachSelectionInput(phase: "readiness check", row: row) + wait(element("inventory-selection-input"), predicate: "value CONTAINS 'window-ready: true'", timeout: 10) try attachSelectionInput(phase: "before click", row: row) // A native outline row's computed hittable point can lie beyond its cells. // Send one real click to the center of the cell identified by this bundle ID. @@ -188,6 +191,13 @@ final class SimulatorInventoryUITests: XCTestCase { attachment.name = "inventory-selection-\(phase)" attachment.lifetime = .keepAlways add(attachment) + let application = try XCTUnwrap(app) + let window = application.windows["main-AppWindow-1"] + XCTAssertTrue(window.exists, "The identified main window must exist for input evidence") + let screenshot = XCTAttachment(screenshot: window.screenshot()) + screenshot.name = "inventory-window-\(phase)" + screenshot.lifetime = .keepAlways + add(screenshot) } private func loadInventory(containing bundle: String) { diff --git a/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift index 1b47d80..5b172b1 100644 --- a/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift +++ b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift @@ -1,4 +1,5 @@ #if DEBUG +import AppKit import DeviceKit import Foundation import Observation @@ -33,6 +34,23 @@ struct InventoryAcceptanceTargets: Sendable { } } +/// An observed AppKit snapshot, without changing activation, focus or selection. +struct InventoryWindowReadiness: Sendable { + let applicationActive: Bool + let mainWindowPresent: Bool + let keyWindowPresent: Bool + let mainWindowKey: Bool + let keyWindowMatchesMain: Bool + + var isReady: Bool { + applicationActive && mainWindowPresent && keyWindowPresent && mainWindowKey && keyWindowMatchesMain + } + + var diagnostic: String { + "window-ready: \(isReady); app-active: \(applicationActive); main-present: \(mainWindowPresent); key-present: \(keyWindowPresent); main-key: \(mainWindowKey); key-is-main: \(keyWindowMatchesMain)" + } +} + /// Holds one successful real A inventory reply until the native UI test explicitly releases it. /// Cancellation deliberately does not discard that reply: the production inventory generation /// check must reject its late delivery after B has loaded. Delivery is acknowledged only after @@ -48,6 +66,7 @@ final class InventoryAcceptance { private(set) var state: State = .idle private(set) var isDeletingFirst = false private(set) var selectionInputTrace: [String] = [] + private(set) var windowReadiness: InventoryWindowReadiness? = nil private let base: ProcessCommandRunner private var held: (result: CommandResult, continuation: CheckedContinuation)? private var deadline: Task? @@ -64,6 +83,18 @@ final class InventoryAcceptance { selectionInputTrace = Array((selectionInputTrace + ["[\(before)] → [\(after)]"]).suffix(8)) } + func recordWindowReadiness(application: NSApplication) { + let mainWindow: NSWindow? = application.mainWindow + let keyWindow: NSWindow? = application.keyWindow + windowReadiness = InventoryWindowReadiness( + applicationActive: application.isActive, + mainWindowPresent: mainWindow != nil, + keyWindowPresent: keyWindow != nil, + mainWindowKey: mainWindow?.isKeyWindow == true, + keyWindowMatchesMain: mainWindow != nil && keyWindow != nil && keyWindow === mainWindow + ) + } + func arm() throws(ToolkitError) { guard state == .idle else { throw ToolkitError(.invalidInput, message: "The inventory acceptance gate has already been armed.", recovery: "Relaunch the acceptance test to capture another inventory reply.", technicalDetail: nil) diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index b3c71a6..ea8b94b 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -2,6 +2,10 @@ import DeviceKit import SwiftUI import ToolkitCore import ToolkitFeatures +#if DEBUG +import AppKit +import Combine +#endif struct AppsView: View { @Environment(AppModel.self) private var model @@ -30,7 +34,11 @@ struct AppsView: View { .font(.caption) .lineLimit(1) .accessibilityIdentifier("inventory-selection-input") - .accessibilityValue(Text(verbatim: "writes: \(acceptance.selectionInputTrace.joined(separator: "; ")); current: \(apps.selection.sorted().joined(separator: ",").prefix(256)); loading: \(apps.isLoading)")) + .accessibilityValue(Text(verbatim: "writes: \(acceptance.selectionInputTrace.joined(separator: "; ")); current: \(apps.selection.sorted().joined(separator: ",").prefix(256)); loading: \(apps.isLoading); \(acceptance.windowReadiness.map(\.diagnostic) ?? "window-readiness: unobserved")")) + } + .onAppear { acceptance.recordWindowReadiness(application: .shared) } + .onReceive(inventoryWindowReadinessEvents) { _ in + acceptance.recordWindowReadiness(application: .shared) } } #endif @@ -133,6 +141,17 @@ struct AppsView: View { } } +#if DEBUG + private var inventoryWindowReadinessEvents: Publishers.MergeMany { + let events: [Notification.Name] = [ + NSApplication.didBecomeActiveNotification, NSApplication.didResignActiveNotification, + NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification, + NSWindow.didBecomeMainNotification, NSWindow.didResignMainNotification, + ] + return Publishers.MergeMany(events.map { NotificationCenter.default.publisher(for: $0, object: nil) }) + } +#endif + private var appSelection: Binding> { @Bindable var apps = model.apps #if DEBUG From 4121a0b6a5370364583dc80f26252e4224c5151c Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:42:38 -0800 Subject: [PATCH 13/16] Check inventory click reachability on the identified cell --- App/UITests/SimulatorInventoryUITests.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index a2af659..579af5e 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -166,15 +166,26 @@ final class SimulatorInventoryUITests: XCTestCase { } private func selectApp(_ bundle: String) throws { - let rows = element("installed-apps-table").outlineRows.containing(.any, identifier: "installed-app-\(bundle)") + let table = element("installed-apps-table") + let rows = table.outlineRows.containing(.any, identifier: "installed-app-\(bundle)") XCTAssertEqual(rows.count, 1, "The stable app identifier must resolve to one native outline row") let row = rows.element - wait(row, predicate: "exists == true AND hittable == true", timeout: 10) - let name = row.descendants(matching: .any)["installed-app-\(bundle)"] + wait(row, predicate: "exists == true", timeout: 10) + // Native outline containers can be unhittable while their identified cells are reachable. + // Require reachability on the actual click target and selection on its unique parent row. + let names = table.staticTexts.matching(identifier: "installed-app-\(bundle)") + XCTAssertEqual(names.count, 1, "The click target must be one identified name cell in this table") + let name = names.element wait(name, predicate: "exists == true AND hittable == true", timeout: 10) try attachSelectionInput(phase: "readiness check", row: row) wait(element("inventory-selection-input"), predicate: "value CONTAINS 'window-ready: true'", timeout: 10) try attachSelectionInput(phase: "before click", row: row) + let window = try XCTUnwrap(app).windows["main-AppWindow-1"] + let frame = name.frame + XCTAssertGreaterThan(frame.width, 0) + XCTAssertGreaterThan(frame.height, 0) + XCTAssertTrue(window.frame.contains(CGPoint(x: frame.midX, y: frame.midY)), + "The identified cell's click center must be inside the main window") // A native outline row's computed hittable point can lie beyond its cells. // Send one real click to the center of the cell identified by this bundle ID. name.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() From 43c37883a241eba4b4dd06a1ff4e5951f9d6a9c3 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:54:31 -0800 Subject: [PATCH 14/16] hideouts/app-inventory: verify failed refresh and recovery Exercise a real owned-simulator inventory failure, retain A provenance and action denial, recover B through the production Start route, and verify explicit confirmation cancellation. Local strict package and app/UI compilation pass; hosted native acceptance and current-candidate Swift CodeQL remain pending on this draft PR. --- App/UITests/SimulatorInventoryUITests.swift | 77 ++++++++++++++++++- .../Components/Components.swift | 4 +- App/iOSDeveloperToolkit/Views/AppsViews.swift | 4 + .../Views/DeviceViews.swift | 4 +- scripts/SimulatorInventoryFixtures.swift | 29 ++++++- 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index 579af5e..6ccfac0 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -59,6 +59,9 @@ final class SimulatorInventoryUITests: XCTestCase { loadInventory(containing: aOnly) try selectApp(aOnly) wait(element("apps-launch"), predicate: "exists == true AND enabled == true", timeout: 10) + let sourceA = try XCTUnwrap(element("apps-inventory-source").value as? String) + XCTAssertFalse(sourceA.isEmpty, "A's inventory must expose its original source") + XCTAssertEqual(element("inventory-loaded-owner").value as? String, a) selectSimulator(b) wait(element("apps-launch"), predicate: "exists == false", timeout: 10) @@ -70,11 +73,55 @@ final class SimulatorInventoryUITests: XCTestCase { assertStaleContextActionsAreDisabled(row: element("installed-app-\(aOnly)")) XCTAssertFalse(element("running-operations").exists, "Blocked controls must start no operation") + // Fixture preparation proves that a real listapps command fails for shut-down B. + // The app must report that failure without relabelling A's retained inventory as B. + element("load-apps").click() + wait(element("error-message"), predicate: "exists == true", timeout: 75) + XCTAssertEqual(application.sheets.count, 1) + XCTAssertEqual(element("error-message").label, "The simulator is not running.") + XCTAssertEqual(element("error-recovery").label, "Start the simulator, then try again.") + let dismissError = element("error-dismiss") + wait(dismissError, predicate: "exists == true AND hittable == true", timeout: 10) + dismissError.click() + wait(element("error-message"), predicate: "exists == false", timeout: 10) + wait(element("running-operations"), predicate: "exists == false", timeout: 10) + XCTAssertTrue(element("installed-app-\(aOnly)").exists) + XCTAssertFalse(element("installed-app-\(bOnly)").exists) + XCTAssertTrue(element("apps-stale-inventory").exists) + XCTAssertEqual(element("inventory-loaded-owner").value as? String, a) + XCTAssertEqual(element("apps-inventory-source").value as? String, sourceA) + XCTAssertFalse(element("apps-launch").exists, "A failed refresh must clear the previous row selection") + try selectApp(aOnly) + wait(element("apps-launch"), predicate: "exists == true AND enabled == false", timeout: 10) + XCTAssertFalse(element("apps-remove").isEnabled) + assertStaleContextActionsAreDisabled(row: element("installed-app-\(aOnly)")) + XCTAssertFalse(element("running-operations").exists, "Failed-refresh stale controls must start no operation") + + // Recover through the ordinary Device & DDI Start action, preserving its captured B + // target and real bootstatus deadline rather than replacing the inventory response. + startSelectedSimulator() + XCTAssertEqual(element("inventory-loaded-owner").value as? String, a) + XCTAssertEqual(element("apps-inventory-source").value as? String, sourceA) loadInventory(containing: bOnly) XCTAssertFalse(element("installed-app-\(aOnly)").exists) XCTAssertFalse(element("apps-stale-inventory").exists) + XCTAssertEqual(element("inventory-loaded-owner").value as? String, b) try selectApp(shared) wait(element("apps-remove"), predicate: "exists == true AND enabled == true", timeout: 10) + let sourceB = try XCTUnwrap(element("apps-inventory-source").value as? String) + element("apps-remove").click() + wait(element("confirm-button"), predicate: "exists == true AND enabled == false", timeout: 10) + XCTAssertEqual(application.sheets.count, 1) + let cancelRemoval = element("confirmation-cancel") + wait(cancelRemoval, predicate: "exists == true AND hittable == true", timeout: 10) + cancelRemoval.click() + wait(element("confirm-button"), predicate: "exists == false", timeout: 10) + XCTAssertEqual(application.sheets.count, 0) + XCTAssertTrue(element("installed-app-\(shared)").exists) + XCTAssertEqual(element("inventory-loaded-owner").value as? String, b) + XCTAssertEqual(element("apps-inventory-source").value as? String, sourceB) + XCTAssertTrue(element("apps-remove").isEnabled) + XCTAssertFalse(element("running-operations").exists, "Cancelling removal must start no operation") element("apps-remove").click() confirmRemoval(on: b) wait(element("installed-app-\(shared)"), predicate: "exists == false", timeout: 120) @@ -133,7 +180,7 @@ final class SimulatorInventoryUITests: XCTestCase { 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") + print("[inventory acceptance] real failed-B refresh with retained A provenance, production B recovery, explicit removal cancellation, stale controls, B-only removal, delivered late-A rejection, and target-loss confirmation dismissal passed") } private func element(_ identifier: String) -> XCUIElement { @@ -165,6 +212,34 @@ final class SimulatorInventoryUITests: XCTestCase { wait(target, predicate: "exists == false", timeout: 10) } + private func startSelectedSimulator() { + let deviceWorkspace = element("sidebar-device") + wait(deviceWorkspace, predicate: "exists == true AND hittable == true", timeout: 10) + deviceWorkspace.click() + let page = element("workspace-device") + wait(page, predicate: "exists == true", timeout: 10) + let start = element("simulator-start") + wait(start, predicate: "exists == true AND enabled == true", timeout: 10) + // The Simulator card follows the device identity and status. Scroll its identified + // workspace only until the actual Start button is reachable, without fallback clicks. + for _ in 0..<8 { + if start.isHittable { break } + XCTAssertGreaterThan(page.frame.height, 0) + page.scroll(byDeltaX: 0, deltaY: -page.frame.height / 2) + } + XCTAssertTrue(start.isHittable, "The production simulator Start button must be reachable") + start.click() + // SimulatorClient.boot uses bootstatus's existing 600-second command deadline. + // Allow that operation, process cleanup and the next observed discovery snapshot. + wait(start, predicate: "enabled == false", timeout: 630) + wait(element("running-operations"), predicate: "exists == false", timeout: 630) + XCTAssertEqual(app?.sheets.count, 0, "Production simulator startup must not present an error") + let appsWorkspace = element("sidebar-apps") + wait(appsWorkspace, predicate: "exists == true AND hittable == true", timeout: 10) + appsWorkspace.click() + wait(element("installed-apps-table"), predicate: "exists == true", timeout: 10) + } + private func selectApp(_ bundle: String) throws { let table = element("installed-apps-table") let rows = table.outlineRows.containing(.any, identifier: "installed-app-\(bundle)") diff --git a/App/iOSDeveloperToolkit/Components/Components.swift b/App/iOSDeveloperToolkit/Components/Components.swift index d6abf75..63dcfe2 100644 --- a/App/iOSDeveloperToolkit/Components/Components.swift +++ b/App/iOSDeveloperToolkit/Components/Components.swift @@ -299,8 +299,10 @@ struct ErrorSheet: View { .font(.title2.bold()) .foregroundStyle(.orange) Text(error.message).font(.body).fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("error-message") if let recovery = error.recovery { Text(recovery).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("error-recovery") } if let details = error.details, !details.isEmpty { DisclosureGroup("Technical details") { @@ -318,11 +320,11 @@ struct ErrorSheet: View { HStack { Spacer() Button("OK") { dismiss() }.keyboardShortcut(.defaultAction) + .accessibilityIdentifier("error-dismiss") } } .padding(20) .frame(width: 520) - .accessibilityIdentifier("error-sheet") } } diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index ea8b94b..f0c1da6 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -30,6 +30,10 @@ struct AppsView: View { .accessibilityIdentifier("inventory-gate-release") Text(acceptance.state.rawValue) .accessibilityIdentifier("inventory-gate-state") + Text("Inventory owner") + .font(.caption) + .accessibilityIdentifier("inventory-loaded-owner") + .accessibilityValue(Text(verbatim: apps.loadedFor?.udid ?? "none")) Text("Selection input") .font(.caption) .lineLimit(1) diff --git a/App/iOSDeveloperToolkit/Views/DeviceViews.swift b/App/iOSDeveloperToolkit/Views/DeviceViews.swift index 63c8861..13d3fbf 100644 --- a/App/iOSDeveloperToolkit/Views/DeviceViews.swift +++ b/App/iOSDeveloperToolkit/Views/DeviceViews.swift @@ -240,7 +240,9 @@ struct DeviceDetailView: View { private func simulatorControls(_ device: Device) -> some View { Card(title: "Simulator", systemImage: "macwindow") { HStack { - Button("Start") { runAction("sim-boot", device) }.disabled(device.simulatorState == .booted) + Button("Start") { runAction("sim-boot", device) } + .disabled(device.simulatorState == .booted) + .accessibilityIdentifier("simulator-start") Button("Show in Simulator") { runAction("sim-open", device) } Button("Shut Down") { confirmation = PendingConfirmation(title: "Shut down \(device.name)", detail: "Running apps in the simulator stop.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { diff --git a/scripts/SimulatorInventoryFixtures.swift b/scripts/SimulatorInventoryFixtures.swift index f936e1f..c78520c 100644 --- a/scripts/SimulatorInventoryFixtures.swift +++ b/scripts/SimulatorInventoryFixtures.swift @@ -82,7 +82,34 @@ struct SimulatorInventoryFixtures { 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.") + try await prepareSecondInventoryFailure(identifier: second, owner: owner, runner: runner) + print("[inventory fixtures] Two owned inventories are ready; B is shut down for real read-failure and production-start recovery acceptance.") + } + + /// Proves the runner's actual shutdown-state listapps failure before UI acceptance uses it. + /// B retains its UUID and installed apps; the app must start it through its production route. + static func prepareSecondInventoryFailure(identifier: String, owner: String, runner: ProcessCommandRunner) async throws { + let expectedName = "IDT Inventory B \(owner)" + guard try await simulators(runner: runner).contains(where: { + $0.identifier == identifier && $0.name == expectedName && $0.available && $0.state == "Booted" + }) else { + throw failure("The B inventory preflight requires this run’s booted, owner-matched simulator.", cause: nil) + } + _ = try await command(["simctl", "listapps", identifier], timeout: 60, runner: runner) + _ = try await command(["simctl", "shutdown", identifier], timeout: 120, runner: runner) + guard try await simulators(runner: runner).contains(where: { + $0.identifier == identifier && $0.name == expectedName && $0.available && $0.state == "Shutdown" + }) else { + throw failure("The owned B simulator did not reach Shutdown during inventory preflight.", cause: nil) + } + let request = CommandRequest(executable: try AppleTool.xcrun.locate(), arguments: ["simctl", "listapps", identifier], timeout: 60, displayName: "inventory shutdown-state read preflight") + let result = try await runner.run(request) + guard case .exited(let code) = result.termination, code != 0, + result.standardErrorText.contains("current state: Shutdown") else { + throw failure("The hosted runner did not produce the required real shutdown-state inventory failure; no failure was injected.", cause: nil) + .appendingDetail(String(result.technicalSummary.prefix(1536))) + } + print("[inventory fixtures] Real B listapps preflight: booted read succeeded; shutdown read exited \(code) with current state: Shutdown.") } static func cleanup(directory: URL, runner: ProcessCommandRunner) async throws { From 44e129a502430b2c76f62f695bbdb6e0d7b2cbec Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:11:12 -0800 Subject: [PATCH 15/16] hideouts/app-inventory: observe Demo input readiness Require actual main/key-window readiness and native row selection around one real Demo inventory click. Preserve every action-denial assertion and the existing five-second bounds. Shared read-only diagnostics capture readiness and selection without changing focus, binding or selection. The prior hosted click was inside the cell; its missing key-window evidence is a capture gap, so no historical focus cause is asserted. Strict local app/UI compilation passes; hosted native validation remains open. --- App/UITests/SmokeUITests.swift | 42 ++++++++++++++++--- App/iOSDeveloperToolkit/Model/AppModel.swift | 2 + .../Model/InventoryAcceptance.swift | 23 ++++++---- App/iOSDeveloperToolkit/Views/AppsViews.swift | 13 ++++++ 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/App/UITests/SmokeUITests.swift b/App/UITests/SmokeUITests.swift index bf65353..07c8ed7 100644 --- a/App/UITests/SmokeUITests.swift +++ b/App/UITests/SmokeUITests.swift @@ -217,10 +217,30 @@ final class SmokeUITests: XCTestCase { let load = app.buttons["load-apps"] XCTAssertTrue(load.waitForExistence(timeout: 5)) load.click() - let row = app.descendants(matching: .any)["installed-app-com.example.sample"] + let table = app.descendants(matching: .any)["installed-apps-table"] + let names = table.staticTexts.matching(identifier: "installed-app-com.example.sample") + let row = names.element XCTAssertTrue(row.waitForExistence(timeout: 5)) + XCTAssertEqual(names.count, 1, "The demo app must resolve to one identified name cell") XCTAssertTrue(row.isHittable, "The demo inventory row must be visible before selection") - row.click() + let nativeRows = table.outlineRows.containing(.any, identifier: "installed-app-com.example.sample") + XCTAssertEqual(nativeRows.count, 1, "The identified app must belong to one native outline row") + let nativeRow = nativeRows.element + let input = app.descendants(matching: .any)["demo-inventory-input"] + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == true AND value CONTAINS 'window-ready: true'"), object: input) + XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 5), .completed, + "Observe the actual key/main window before the single inventory click") + try attachDemoInventoryInput(phase: "before click", input: input, row: nativeRow) + let window = app.windows["main-AppWindow-1"] + XCTAssertTrue(window.exists) + XCTAssertGreaterThan(row.frame.width, 0) + XCTAssertGreaterThan(row.frame.height, 0) + XCTAssertTrue(window.frame.contains(CGPoint(x: row.frame.midX, y: row.frame.midY))) + row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() + try attachDemoInventoryInput(phase: "after click", input: input, row: nativeRow) + let selected = XCTNSPredicateExpectation(predicate: NSPredicate(format: "selected == true"), object: nativeRow) + XCTAssertEqual(XCTWaiter.wait(for: [selected], timeout: 5), .completed, + "The single click must select the actual native outline row") let launch = app.buttons["apps-launch"] let remove = app.buttons["apps-remove"] @@ -229,10 +249,8 @@ final class SmokeUITests: XCTestCase { 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") + XCTAssertTrue(row.isHittable, "The identified cell 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. @@ -249,6 +267,20 @@ final class SmokeUITests: XCTestCase { attachScreenshot("demo-app-actions-blocked") } + private func attachDemoInventoryInput(phase: String, input: XCUIElement, row: XCUIElement) throws { + let trace = try XCTUnwrap(input.value as? String) + let attachment = XCTAttachment(string: "\(phase): selected=\(row.isSelected); \(trace)") + attachment.name = "demo-inventory-\(phase)" + attachment.lifetime = .keepAlways + add(attachment) + let window = app.windows["main-AppWindow-1"] + XCTAssertTrue(window.exists) + let screenshot = XCTAttachment(screenshot: window.screenshot()) + screenshot.name = "demo-inventory-window-\(phase)" + screenshot.lifetime = .keepAlways + add(screenshot) + } + 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 361671a..8a8eaa8 100644 --- a/App/iOSDeveloperToolkit/Model/AppModel.swift +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -68,6 +68,7 @@ final class AppModel { let runner: OwnedCommandRunning #if DEBUG let inventoryAcceptance: InventoryAcceptance? + let isDemoInventoryUITesting: Bool #endif let journal = OperationJournal() let discovery: DeviceDiscovery @@ -142,6 +143,7 @@ final class AppModel { let acceptanceRequested = arguments.contains("-inventory-acceptance-a") || arguments.contains("-inventory-acceptance-b") || arguments.contains("-inventory-acceptance-owner") let acceptanceError: ToolkitError? #if DEBUG + isDemoInventoryUITesting = uiTesting && isDemoMode do { if let targets = try InventoryAcceptanceTargets.parse(arguments: arguments, uiTesting: uiTesting, demoMode: isDemoMode) { let base = ProcessCommandRunner() diff --git a/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift index 5b172b1..1aa7ad6 100644 --- a/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift +++ b/App/iOSDeveloperToolkit/Model/InventoryAcceptance.swift @@ -42,6 +42,19 @@ struct InventoryWindowReadiness: Sendable { let mainWindowKey: Bool let keyWindowMatchesMain: Bool + @MainActor + static func observe(application: NSApplication) -> InventoryWindowReadiness { + let mainWindow: NSWindow? = application.mainWindow + let keyWindow: NSWindow? = application.keyWindow + return InventoryWindowReadiness( + applicationActive: application.isActive, + mainWindowPresent: mainWindow != nil, + keyWindowPresent: keyWindow != nil, + mainWindowKey: mainWindow?.isKeyWindow == true, + keyWindowMatchesMain: mainWindow != nil && keyWindow != nil && keyWindow === mainWindow + ) + } + var isReady: Bool { applicationActive && mainWindowPresent && keyWindowPresent && mainWindowKey && keyWindowMatchesMain } @@ -84,15 +97,7 @@ final class InventoryAcceptance { } func recordWindowReadiness(application: NSApplication) { - let mainWindow: NSWindow? = application.mainWindow - let keyWindow: NSWindow? = application.keyWindow - windowReadiness = InventoryWindowReadiness( - applicationActive: application.isActive, - mainWindowPresent: mainWindow != nil, - keyWindowPresent: keyWindow != nil, - mainWindowKey: mainWindow?.isKeyWindow == true, - keyWindowMatchesMain: mainWindow != nil && keyWindow != nil && keyWindow === mainWindow - ) + windowReadiness = .observe(application: application) } func arm() throws(ToolkitError) { diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index f0c1da6..ea9bb33 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -10,12 +10,25 @@ import Combine struct AppsView: View { @Environment(AppModel.self) private var model @State private var confirmation: PendingConfirmation? +#if DEBUG + @State private var demoWindowReadiness: InventoryWindowReadiness? +#endif var body: some View { @Bindable var apps = model.apps VStack(alignment: .leading, spacing: 12) { TargetHeader() #if DEBUG + if model.isDemoInventoryUITesting { + Text("Inventory input") + .font(.caption) + .accessibilityIdentifier("demo-inventory-input") + .accessibilityValue(Text(verbatim: "\(demoWindowReadiness.map(\.diagnostic) ?? "window-readiness: unobserved"); current: \(apps.selection.sorted().joined(separator: ",")); loading: \(apps.isLoading)")) + .onAppear { demoWindowReadiness = .observe(application: .shared) } + .onReceive(inventoryWindowReadinessEvents) { _ in + demoWindowReadiness = .observe(application: .shared) + } + } if let acceptance = model.inventoryAcceptance { HStack { Button("Arm inventory acceptance") { From 27b3654a7e064b906d237d04c2414d1b475766e8 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:32:08 -0800 Subject: [PATCH 16/16] hideouts/app-inventory: read native error text values Read the macOS StaticText values using checked string unwraps and retain exact message/recovery equality plus bounded typed receipts. The previous hosted recording displays the expected real shutdown error; its label was empty. Local strict UI compilation passes. Hosted recovery and current-candidate Swift CodeQL remain pending. --- App/UITests/SimulatorInventoryUITests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/App/UITests/SimulatorInventoryUITests.swift b/App/UITests/SimulatorInventoryUITests.swift index 6ccfac0..d1f2883 100644 --- a/App/UITests/SimulatorInventoryUITests.swift +++ b/App/UITests/SimulatorInventoryUITests.swift @@ -78,8 +78,14 @@ final class SimulatorInventoryUITests: XCTestCase { element("load-apps").click() wait(element("error-message"), predicate: "exists == true", timeout: 75) XCTAssertEqual(application.sheets.count, 1) - XCTAssertEqual(element("error-message").label, "The simulator is not running.") - XCTAssertEqual(element("error-recovery").label, "Start the simulator, then try again.") + let errorMessage = try XCTUnwrap(element("error-message").value as? String) + let errorRecovery = try XCTUnwrap(element("error-recovery").value as? String) + let failureText = XCTAttachment(string: "message: \(errorMessage.prefix(2048))\nrecovery: \(errorRecovery.prefix(2048))") + failureText.name = "inventory-failed-refresh-error" + failureText.lifetime = .keepAlways + add(failureText) + XCTAssertEqual(errorMessage, "The simulator is not running.") + XCTAssertEqual(errorRecovery, "Start the simulator, then try again.") let dismissError = element("error-dismiss") wait(dismissError, predicate: "exists == true AND hittable == true", timeout: 10) dismissError.click()