Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,62 @@ jobs:
-configuration Debug -destination 'platform=macOS' \
-derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \
test-without-building
- name: Build the inventory fixture host
run: |
xcrun swiftc -swift-version 6 -warnings-as-errors -parse-as-library \
-I "$RUNNER_TEMP/dd/Build/Products/Debug" \
"$RUNNER_TEMP/dd/Build/Products/Debug/ToolkitCore.o" \
scripts/SimulatorInventoryFixtures.swift -o "$RUNNER_TEMP/inventory-fixtures"
- name: Prepare two owned inventory simulators
id: prepare_inventory
run: |
"$RUNNER_TEMP/inventory-fixtures" prepare "$RUNNER_TEMP/inventory-fixtures-data"
python3 - "$RUNNER_TEMP/inventory-fixtures-data/manifest.json" <<'PY'
import json
import os
import sys
import uuid
from pathlib import Path

manifest = json.loads(Path(sys.argv[1]).read_text())
if not isinstance(manifest, dict):
raise SystemExit("Inventory fixture host did not produce an object manifest.")
for key in ("first", "second", "owner"):
value = manifest.get(key)
if not isinstance(value, str) or str(uuid.UUID(value)).upper() != value:
raise SystemExit(f"Inventory fixture manifest has no canonical {key} UUID.")
if manifest["first"] == manifest["second"]:
raise SystemExit("Inventory fixtures must use distinct simulators.")
with open(os.environ["GITHUB_ENV"], "a") as output:
output.write("TEST_RUNNER_IDT_INVENTORY_MANIFEST=" + json.dumps({key: manifest[key] for key in ("first", "second", "owner")}, separators=(",", ":")) + "\n")
PY
- name: Two-owned-simulator inventory UI acceptance
env:
TEST_RUNNER_IDT_INVENTORY_UI_TESTS: "1"
run: |
xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \
-configuration Debug -destination 'platform=macOS' \
-derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/InventoryUITests.xcresult" \
-only-testing:iOSDeveloperToolkitUITests/SimulatorInventoryUITests test-without-building
xcrun xcresulttool get test-results summary --path "$RUNNER_TEMP/InventoryUITests.xcresult" \
> "$RUNNER_TEMP/InventoryUITests-summary.json"
python3 - "$RUNNER_TEMP/InventoryUITests-summary.json" <<'PY'
import json
import sys
from pathlib import Path

summary = json.loads(Path(sys.argv[1]).read_text())
expected = {"totalTestCount": 1, "passedTests": 1, "failedTests": 0, "skippedTests": 0}
if not isinstance(summary, dict) or any(type(summary.get(key)) is not int for key in expected):
raise SystemExit("Inventory acceptance did not produce valid execution counts.")
observed = {key: summary[key] for key in expected}
if observed != expected:
raise SystemExit(f"Inventory acceptance must execute and pass exactly one test: {observed}")
print(f"Two-owned-simulator native acceptance executed: {observed}")
PY
- name: Verify cleanup of both owned inventory simulators
if: ${{ always() && (steps.prepare_inventory.outcome == 'success' || steps.prepare_inventory.outcome == 'failure' || steps.prepare_inventory.outcome == 'cancelled') }}
run: '"$RUNNER_TEMP/inventory-fixtures" cleanup "$RUNNER_TEMP/inventory-fixtures-data"'
- name: Render every page at the default and minimum window sizes
run: |
scripts/check-layout.sh \
Expand All @@ -98,6 +154,8 @@ jobs:
name: ui-test-results
path: |
${{ runner.temp }}/UITests.xcresult
${{ runner.temp }}/InventoryUITests.xcresult
${{ runner.temp }}/InventoryUITests-summary.json
${{ runner.temp }}/layout
if-no-files-found: ignore

Expand Down
340 changes: 340 additions & 0 deletions App/UITests/SimulatorInventoryUITests.swift

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions App/UITests/SmokeUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,75 @@ 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 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")
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"]
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")

XCTAssertEqual(table.outlineRows.count, 1, "The demo inventory must expose exactly one visible row")
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.
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")
}

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)] = [
Expand Down
6 changes: 5 additions & 1 deletion App/iOSDeveloperToolkit/Components/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()
Expand All @@ -297,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") {
Expand All @@ -316,11 +320,11 @@ struct ErrorSheet: View {
HStack {
Spacer()
Button("OK") { dismiss() }.keyboardShortcut(.defaultAction)
.accessibilityIdentifier("error-dismiss")
}
}
.padding(20)
.frame(width: 520)
.accessibilityIdentifier("error-sheet")
}
}

Expand Down
58 changes: 51 additions & 7 deletions App/iOSDeveloperToolkit/Model/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ final class RunningOperation: Identifiable {
final class AppModel {
// MARK: Services

let runner: OwnedCommandRunning = ProcessCommandRunner()
let runner: OwnedCommandRunning
#if DEBUG
let inventoryAcceptance: InventoryAcceptance?
let isDemoInventoryUITesting: Bool
#endif
let journal = OperationJournal()
let discovery: DeviceDiscovery
/// A fresh executor carries the current developer-image folders.
Expand All @@ -76,7 +80,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).
Expand Down Expand Up @@ -130,11 +137,42 @@ final class AppModel {
}
let uiTesting = flag("-ui-testing") ?? false
let forceDemo = flag("-demo-mode") ?? false
demoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode"))
let isDemoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode"))
demoMode = isDemoMode
selectedDeviceID = forceDemo ? DemoMode.device.id : UserDefaults.standard.string(forKey: "selectedDeviceID")
// UI tests run without touching real discovery sources.
let configuration: DeviceDiscovery.Configuration = uiTesting
? .init(usbmux: USBMuxClient(socketPath: "/nonexistent/ui-testing-usbmuxd"), coreDevice: nil, simulators: nil, enrichWithLockdown: false)
let acceptanceRequested = arguments.contains("-inventory-acceptance-a") || arguments.contains("-inventory-acceptance-b") || arguments.contains("-inventory-acceptance-owner")
let acceptanceError: ToolkitError?
#if DEBUG
isDemoInventoryUITesting = uiTesting && isDemoMode
do {
if let targets = try InventoryAcceptanceTargets.parse(arguments: arguments, uiTesting: uiTesting, demoMode: isDemoMode) {
let base = ProcessCommandRunner()
let acceptance = InventoryAcceptance(targets: targets, base: base)
inventoryAcceptance = acceptance
runner = InventoryAcceptanceRunner(base: base, acceptance: acceptance)
} else {
inventoryAcceptance = nil
runner = ProcessCommandRunner()
}
acceptanceError = nil
} catch {
inventoryAcceptance = nil
runner = ProcessCommandRunner()
acceptanceError = error
}
#else
runner = ProcessCommandRunner()
acceptanceError = acceptanceRequested ? ToolkitError(.unsupported, message: "Inventory acceptance requires a Debug build.", recovery: "Build the Debug configuration and rerun the native acceptance test.", technicalDetail: nil) : nil
#endif
let acceptanceSimulators: SimulatorClient?
#if DEBUG
acceptanceSimulators = inventoryAcceptance == nil ? nil : SimulatorClient(runner: runner)
#else
acceptanceSimulators = nil
#endif
// UI tests exclude physical discovery; only the explicit acceptance launch enables simulators.
let configuration: DeviceDiscovery.Configuration = uiTesting || acceptanceRequested
? .init(usbmux: USBMuxClient(socketPath: "/nonexistent/ui-testing-usbmuxd"), coreDevice: nil, simulators: acceptanceSimulators, enrichWithLockdown: false)
: .init()
discovery = DeviceDiscovery(configuration: configuration)
developerImage = DeveloperImageModel()
Expand All @@ -150,6 +188,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
Expand Down Expand Up @@ -208,7 +247,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 } }
Expand Down
Loading
Loading