#!/usr/bin/env bun /** * Real-iPhone deployment smoke harness for the ios-qa DebugBridge. * * This intentionally uses Xcode/CoreDevice and the existing daemon bootstrap. * It does not use XCTest, XCUITest, Appium, WebDriverAgent, a simulator, or a * cloud-device provider. */ import { createHash, randomUUID } from 'crypto'; import { spawnSync } from 'child_process'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'fs'; import { tmpdir } from 'os'; import { join, relative } from 'path'; import { bootstrapTunnel, type BootstrapErrorReason } from '../daemon/src/tunnel-bootstrap'; import { copyFileFromAppContainer, startTunnelKeepalive, } from '../daemon/src/devicectl'; import type { DeviceTunnel } from '../daemon/src/proxy'; export const PHYSICAL_DEVICE_BUNDLE_ID = 'com.gstack.iosqa.fixture.gstack2'; export const REQUIRED_LIVE_ITERATIONS = 5; export const TEAM_ID_ENV = 'GSTACK_IOS_DEVELOPMENT_TEAM'; export const TEAM_ID_ENV_ALIAS = 'GSTACK_IOS_TEAM_ID'; export const REPLACE_CONFLICT_ENV = 'GSTACK_IOS_ALLOW_REPLACE_FIXTURE'; const ROOT = join(import.meta.dir, '..', '..'); const FIXTURE_SOURCE = join(ROOT, 'test', 'fixtures', 'ios-qa', 'FixtureApp'); const EVIDENCE_DIR = join(ROOT, 'docs', 'gstack-2', 'evidence'); const BOOT_TOKEN_PATH = 'tmp/gstack-ios-qa.token'; export type FailureCategory = 'setup_gate' | 'safety_refusal' | 'product_failure'; export type HarnessErrorCode = | 'macos_required' | 'xcode_unavailable' | 'xcode_not_initialized' | 'devicectl_unavailable' | 'xcodegen_unavailable' | 'devtools_security_disabled' | 'device_discovery_failed' | 'device_discovery_bad_response' | 'no_iphone' | 'device_not_found' | 'multiple_iphones' | 'unsupported_device_type' | 'device_not_wired' | 'device_not_paired_or_trusted' | 'developer_mode_disabled' | 'device_locked' | 'device_management_unavailable' | 'invalid_team_id' | 'conflicting_team_ids' | 'installed_apps_unavailable' | 'existing_bundle_conflict' | 'fixture_copy_failed' | 'xcodegen_failed' | 'release_build_failed' | 'release_app_missing' | 'release_symbol_scan_failed' | 'release_debugbridge_leak' | 'signing_unavailable' | 'debug_build_failed' | 'debug_app_missing' | 'install_failed' | 'launch_failed' | 'boot_token_unavailable' | 'coredevice_tunnel_unavailable' | 'bootstrap_failed' | 'live_checks_failed' | 'cleanup_failed' | 'invalid_arguments'; export class HarnessError extends Error { constructor( public readonly code: HarnessErrorCode, public readonly category: FailureCategory, public readonly phase: string, message: string, public readonly remediation: string[] = [], public readonly detail?: string, ) { super(message); this.name = 'HarnessError'; } toJSON(): Record { return { ok: false, code: this.code, category: this.category, phase: this.phase, message: this.message, remediation: this.remediation, ...(this.detail ? { detail: this.detail } : {}), }; } } export interface PhysicalDevice { coreDeviceIdentifier: string; hardwareUdid: string | null; name: string; model: string; platform: string; tunnelState: string; pairingState: string; developerModeStatus: string; transportType: string | null; } export interface ToolchainPreflight { developerDir: string; xcodeVersion: string; xcodeBuildVersion: string; xcodegenVersion: string; devicectlPath: string; devToolsSecurity: 'enabled'; } export interface InstalledApp { bundleIdentifier: string; name: string | null; displayName: string | null; bundleVersion: string | null; url: string | null; } interface CommandResult { status: number | null; stdout: string; stderr: string; error: Error | null; } interface JsonCommandResult { payload: unknown; command: CommandResult; } type ParsedJson = | { ok: true; payload: unknown } | { ok: false; detail: string }; interface ApiResponse { status: number; body: Record; } interface PngSummary { sha256: string; bytes: number; width: number; height: number; } interface FixtureElement { identifier: string; label: string; frame: { x: number; y: number; w: number; h: number }; } interface LiveIterationResult { iteration: number; passed: true; checks: { health_bundle: { passed: true; bundleBefore: string; bundleAfter: string; }; token_rotation: { passed: true; originalBootTokenRejected: true; }; session_acquire: { passed: true; sessionIdIssued: true; released: true; }; screenshot_elements: { passed: true; elementCountBefore: number; elementCountAfter: number; screenshotBefore: PngSummary; screenshotAfter: PngSummary; }; coordinate_tap_state_cleanup: { passed: true; buttonLabelBefore: string; buttonLabelAfter: string; activeBundleBefore: string; activeBundleAfter: string; stateCleanup: 'unchanged' | 'restored'; }; }; } interface FailedLiveIteration { iteration: number; passed: false; error: Record; } interface HarnessEvidence { schemaVersion: 1; kind: 'gstack-ios-qa-physical-device'; passed: true; generatedAt: string; requiredIterations: 5; passedIterations: 5; toolchain: ToolchainPreflight; device: { identifierSha256: string; model: string; platform: string; transportType: string | null; pairingState: string; developerModeStatus: string; }; bundleId: string; signing: { automatic: true; explicitTeamFromEnvironment: boolean; }; installSafety: { existingBundle: 'absent' | 'related_fixture' | 'explicitly_allowed_conflict'; appDataDeleted: false; appUninstalled: false; }; releaseGuard: { built: true; debugBridgeSymbolsAbsent: true; executableSha256: string; }; bootstrap: { transport: 'CoreDevice IPv6'; daemonBootstrap: true; tokenRotated: true; stateServerPort: number; }; iterations: LiveIterationResult[]; cleanup: { sessionsReleased: true; tunnelKeepaliveStopped: true; temporaryWorkspaceRemoved: true; }; } function runCommand( command: string, args: string[], options: { cwd?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv } = {}, ): CommandResult { const result = spawnSync(command, args, { cwd: options.cwd, env: options.env ?? process.env, encoding: 'utf8', stdio: 'pipe', timeout: options.timeoutMs ?? 60_000, maxBuffer: 64 * 1024 * 1024, }); return { status: result.status, stdout: result.stdout?.toString() ?? '', stderr: result.stderr?.toString() ?? '', error: result.error ?? null, }; } function commandDetail(result: CommandResult, lines = 80): string { const combined = `${result.stdout}\n${result.stderr}`.trim(); if (!combined) return result.error?.message ?? `exit status ${result.status ?? 'unknown'}`; return combined.split('\n').slice(-lines).join('\n'); } function parseJson(raw: string): ParsedJson { try { return { ok: true, payload: JSON.parse(raw) }; } catch (error) { return { ok: false, detail: error instanceof Error ? error.message : String(error) }; } } function runJsonDevicectl(args: string[], phase: string): JsonCommandResult { const dir = mkdtempSync(join(tmpdir(), 'gstack-ios-devicectl-')); const output = join(dir, 'result.json'); try { const command = runCommand('xcrun', ['devicectl', ...args, '--json-output', output], { timeoutMs: 60_000, }); if (command.status !== 0) { throw new HarnessError( 'device_discovery_failed', 'setup_gate', phase, `devicectl failed during ${phase}`, ['Reconnect and unlock the iPhone, then rerun `xcrun devicectl list devices`.'], commandDetail(command), ); } if (!existsSync(output)) { throw new HarnessError( 'device_discovery_bad_response', 'setup_gate', phase, 'devicectl exited successfully but did not create its JSON output file', ['Run `sudo xcodebuild -runFirstLaunch`, reconnect the iPhone, and retry.'], ); } const parsed = parseJson(readFileSync(output, 'utf8')); if (!parsed.ok) { throw new HarnessError( 'device_discovery_bad_response', 'setup_gate', phase, 'devicectl returned malformed JSON', ['Upgrade or repair Xcode, then verify `xcrun devicectl list devices --json-output ` manually.'], parsed.detail, ); } return { payload: parsed.payload, command }; } finally { rmSync(dir, { recursive: true, force: true }); } } function objectRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; } function stringValue(value: unknown, fallback = ''): string { return typeof value === 'string' ? value : fallback; } export function parseDeviceListPayload(payload: unknown): PhysicalDevice[] { const root = objectRecord(payload); const result = objectRecord(root?.result); const rawDevices = result?.devices; if (!Array.isArray(rawDevices)) { throw new HarnessError( 'device_discovery_bad_response', 'setup_gate', 'device_discovery', 'devicectl JSON is missing result.devices[]', ['Run `xcrun devicectl list devices --json-output /tmp/devices.json` and inspect the Xcode installation.'], ); } return rawDevices.map((raw, index) => { const device = objectRecord(raw); const connection = objectRecord(device?.connectionProperties); const properties = objectRecord(device?.deviceProperties); const hardware = objectRecord(device?.hardwareProperties); const identifier = stringValue(device?.identifier); if (!identifier) { throw new HarnessError( 'device_discovery_bad_response', 'setup_gate', 'device_discovery', `devicectl device entry ${index} has no identifier`, ['Repair or update Xcode and rerun device discovery.'], ); } const developerModeStatus = stringValue( properties?.developerModeStatus ?? hardware?.developerModeStatus ?? properties?.developerMode, 'unknown', ); return { coreDeviceIdentifier: identifier, hardwareUdid: stringValue(hardware?.udid) || null, name: stringValue(properties?.name, 'unknown'), model: stringValue(hardware?.productType, 'unknown'), platform: stringValue(hardware?.platform, 'unknown'), tunnelState: stringValue(connection?.tunnelState, 'unknown'), pairingState: stringValue(connection?.pairingState, 'unknown'), developerModeStatus, transportType: stringValue(connection?.transportType ?? connection?.connectionType) || null, }; }); } export function discoverPhysicalDevices(): PhysicalDevice[] { const result = runJsonDevicectl(['list', 'devices'], 'device_discovery'); return parseDeviceListPayload(result.payload); } function isIPhone(device: PhysicalDevice): boolean { return device.model.toLowerCase().startsWith('iphone') || device.platform.toLowerCase() === 'ios'; } export function selectPhysicalDevice( devices: PhysicalDevice[], selector?: string, ): PhysicalDevice { if (selector) { const target = devices.find((device) => device.coreDeviceIdentifier === selector || device.hardwareUdid === selector); if (!target) { throw new HarnessError( 'device_not_found', 'setup_gate', 'device_selection', `No CoreDevice entry matches ${selector}`, ['Pass either the hardware UDID or CoreDevice UUID printed by `xcrun devicectl list devices`.'], ); } if (!isIPhone(target)) { throw new HarnessError( 'unsupported_device_type', 'setup_gate', 'device_selection', `${target.name} (${target.model}) is not an iPhone`, ['Set `GSTACK_IOS_TARGET_UDID` to a connected iPhone hardware UDID or CoreDevice UUID.'], ); } return target; } const iphones = devices.filter(isIPhone); if (iphones.length === 0) { throw new HarnessError( 'no_iphone', 'setup_gate', 'device_selection', 'No iPhone is visible to CoreDevice', ['Connect an unlocked iPhone over USB and run `xcrun devicectl list devices`.'], ); } if (iphones.length === 1) return iphones[0]!; const wired = iphones.filter((device) => device.transportType?.toLowerCase() === 'wired'); if (wired.length === 1) return wired[0]!; const choices = iphones .map((device) => `${device.name}: hardware=${device.hardwareUdid ?? 'unknown'} coredevice=${device.coreDeviceIdentifier}`) .join('; '); throw new HarnessError( 'multiple_iphones', 'setup_gate', 'device_selection', 'More than one iPhone is available; the harness will not guess', [`Set GSTACK_IOS_TARGET_UDID=. Choices: ${choices}`], ); } export function resolveTeamId(env: NodeJS.ProcessEnv = process.env): string | undefined { const primary = env[TEAM_ID_ENV]?.trim(); const alias = env[TEAM_ID_ENV_ALIAS]?.trim(); if (primary && alias && primary !== alias) { throw new HarnessError( 'conflicting_team_ids', 'setup_gate', 'signing', `${TEAM_ID_ENV} and ${TEAM_ID_ENV_ALIAS} disagree`, [`Unset one variable or set both to the same Apple development team ID.`], ); } const teamId = primary || alias; if (!teamId) return undefined; if (!/^[A-Z0-9]{10}$/.test(teamId)) { throw new HarnessError( 'invalid_team_id', 'setup_gate', 'signing', `The explicit Apple development team ID is not a 10-character uppercase identifier`, [`Set ${TEAM_ID_ENV}= using the value shown in Xcode Settings > Accounts.`], ); } return teamId; } export function runToolchainPreflight(): ToolchainPreflight { if (process.platform !== 'darwin') { throw new HarnessError( 'macos_required', 'setup_gate', 'host_preflight', 'A real-device CoreDevice deployment requires macOS', ['Run this harness on the Mac physically connected to the iPhone.'], ); } const selected = runCommand('xcode-select', ['-p']); const developerDir = selected.stdout.trim(); if (selected.status !== 0 || !developerDir.includes('.app/Contents/Developer')) { throw new HarnessError( 'xcode_unavailable', 'setup_gate', 'host_preflight', 'The active developer directory is not a full Xcode installation', [ 'Install Xcode, then run `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`.', 'Run `sudo xcodebuild -runFirstLaunch` once after selecting Xcode.', ], commandDetail(selected), ); } const xcode = runCommand('xcodebuild', ['-version']); if (xcode.status !== 0) { throw new HarnessError( 'xcode_unavailable', 'setup_gate', 'host_preflight', 'xcodebuild is unavailable', ['Install/select Xcode and run `sudo xcodebuild -runFirstLaunch`.'], commandDetail(xcode), ); } const versionLines = xcode.stdout.trim().split('\n'); const initialized = runCommand('xcodebuild', ['-checkFirstLaunchStatus']); if (initialized.status !== 0) { throw new HarnessError( 'xcode_not_initialized', 'setup_gate', 'host_preflight', 'Xcode first-launch components or license acceptance are incomplete', ['Run `sudo xcodebuild -runFirstLaunch`, accept any license prompt, then retry.'], commandDetail(initialized), ); } const devicectl = runCommand('xcrun', ['--find', 'devicectl']); if (devicectl.status !== 0 || !devicectl.stdout.trim()) { throw new HarnessError( 'devicectl_unavailable', 'setup_gate', 'host_preflight', 'The selected Xcode does not provide devicectl', [ 'Select a recent full Xcode with `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`.', 'Verify with `xcrun --find devicectl`.', ], commandDetail(devicectl), ); } const xcodegen = runCommand('xcodegen', ['--version']); if (xcodegen.status !== 0) { throw new HarnessError( 'xcodegen_unavailable', 'setup_gate', 'host_preflight', 'xcodegen is required to generate the temporary fixture project', ['Install it with `brew install xcodegen`, then verify `xcodegen --version`.'], commandDetail(xcodegen), ); } const securityTool = existsSync('/usr/sbin/DevToolsSecurity') ? '/usr/sbin/DevToolsSecurity' : 'DevToolsSecurity'; const security = runCommand(securityTool, ['-status']); const securityOutput = `${security.stdout}\n${security.stderr}`; if (security.status !== 0 || !/currently enabled/i.test(securityOutput)) { throw new HarnessError( 'devtools_security_disabled', 'setup_gate', 'host_preflight', 'macOS developer-tool authorization is disabled', ['Run `sudo DevToolsSecurity -enable`, then verify with `DevToolsSecurity -status`.'], commandDetail(security), ); } return { developerDir, xcodeVersion: versionLines[0] ?? 'unknown', xcodeBuildVersion: versionLines[1] ?? 'unknown', xcodegenVersion: xcodegen.stdout.trim() || xcodegen.stderr.trim(), devicectlPath: devicectl.stdout.trim(), devToolsSecurity: 'enabled', }; } function verifyDeviceGates(device: PhysicalDevice): void { if (device.transportType?.toLowerCase() !== 'wired') { throw new HarnessError( 'device_not_wired', 'setup_gate', 'device_preflight', `${device.name} is not reporting a wired CoreDevice connection`, [ 'Connect the iPhone directly by USB, unlock it, and accept the accessory prompt.', `Verify transportType=wired with \`xcrun devicectl list devices --json-output /tmp/devices.json\`.`, ], ); } if (device.pairingState.toLowerCase() !== 'paired') { throw new HarnessError( 'device_not_paired_or_trusted', 'setup_gate', 'device_preflight', `${device.name} is not paired and trusted`, [ `Unlock the iPhone and run \`xcrun devicectl manage pair --device ${device.coreDeviceIdentifier}\`.`, 'Tap Trust on the iPhone and enter its passcode, then reconnect the cable.', ], ); } if (device.developerModeStatus.toLowerCase() !== 'enabled') { throw new HarnessError( 'developer_mode_disabled', 'setup_gate', 'device_preflight', `Developer Mode is ${device.developerModeStatus} on ${device.name}`, [ 'On the iPhone open Settings > Privacy & Security > Developer Mode and turn it on.', 'Restart when prompted, unlock the phone, confirm Enable, then reconnect it.', ], ); } const probe = runJsonDevicectl( ['device', 'info', 'processes', '--device', device.coreDeviceIdentifier], 'device_management_probe', ); if (!objectRecord(objectRecord(probe.payload)?.result)) { throw new HarnessError( 'device_management_unavailable', 'setup_gate', 'device_preflight', 'CoreDevice returned no process-management result', ['Unlock and reconnect the iPhone, then rerun the pairing and Developer Mode steps.'], ); } } function parseInstalledApps(payload: unknown): InstalledApp[] { const apps = objectRecord(objectRecord(payload)?.result)?.apps; if (!Array.isArray(apps)) { throw new HarnessError( 'installed_apps_unavailable', 'safety_refusal', 'install_safety', 'devicectl did not return result.apps[]; installation safety cannot be proven', ['Run `xcrun devicectl device info apps --device --bundle-id ` and retry after CoreDevice is healthy.'], ); } return apps.map((raw) => { const app = objectRecord(raw); return { bundleIdentifier: stringValue(app?.bundleIdentifier), name: stringValue(app?.name) || null, displayName: stringValue(app?.displayName) || null, bundleVersion: stringValue(app?.bundleVersion) || null, url: stringValue(app?.url) || null, }; }); } export function isRelatedFixtureInstall(app: InstalledApp): boolean { if (app.bundleIdentifier !== PHYSICAL_DEVICE_BUNDLE_ID) return false; const names = [app.name, app.displayName] .filter((name): name is string => Boolean(name)) .map((name) => name.toLowerCase()); return names.includes('fixtureapp') || names.includes('ios-qa fixture') || Boolean(app.url?.includes('/FixtureApp.app/')); } function checkInstallSafety( device: PhysicalDevice, allowConflict: boolean, ): 'absent' | 'related_fixture' | 'explicitly_allowed_conflict' { const listed = runJsonDevicectl([ 'device', 'info', 'apps', '--device', device.coreDeviceIdentifier, '--bundle-id', PHYSICAL_DEVICE_BUNDLE_ID, ], 'install_safety'); const matches = parseInstalledApps(listed.payload) .filter((app) => app.bundleIdentifier === PHYSICAL_DEVICE_BUNDLE_ID); if (matches.length === 0) return 'absent'; if (matches.every(isRelatedFixtureInstall)) return 'related_fixture'; if (allowConflict) return 'explicitly_allowed_conflict'; const app = matches[0]!; throw new HarnessError( 'existing_bundle_conflict', 'safety_refusal', 'install_safety', `${PHYSICAL_DEVICE_BUNDLE_ID} is already installed but does not identify as the gstack FixtureApp`, [ `Inspect the existing app first. To explicitly permit an in-place replacement, set ${REPLACE_CONFLICT_ENV}=1.`, 'The harness will never uninstall the app or delete its data.', ], JSON.stringify({ name: app.name, displayName: app.displayName, bundleVersion: app.bundleVersion }), ); } export function renderProjectSpec(includeDebugBridge: boolean): string { const packageSection = includeDebugBridge ? `\npackages:\n DebugBridge:\n path: .\n` : ''; const dependencySection = includeDebugBridge ? `\n dependencies:\n - package: DebugBridge\n product: DebugBridgeCore\n - package: DebugBridge\n product: DebugBridgeUI` : ''; return `name: FixtureApp options: deploymentTarget: iOS: "16.0" bundleIdPrefix: com.gstack.iosqa.fixture.gstack2 developmentLanguage: en createIntermediateGroups: true ${packageSection} targets: FixtureApp: type: application platform: iOS deploymentTarget: "16.0" sources: - path: Sources/FixtureApp${dependencySection} info: path: Sources/FixtureApp/Info.plist properties: CFBundleDisplayName: ios-qa fixture UILaunchScreen: {} UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait] UIRequiredDeviceCapabilities: [arm64] settings: base: PRODUCT_NAME: FixtureApp PRODUCT_BUNDLE_IDENTIFIER: ${PHYSICAL_DEVICE_BUNDLE_ID} CODE_SIGN_STYLE: Automatic TARGETED_DEVICE_FAMILY: "1" SWIFT_VERSION: "5.9" IPHONEOS_DEPLOYMENT_TARGET: "16.0" ENABLE_PREVIEWS: YES `; } function copyFixtureToTemporaryWorkspace(): string { const workspace = mkdtempSync(join(tmpdir(), 'gstack-ios-physical-')); try { cpSync(FIXTURE_SOURCE, workspace, { recursive: true, filter: (source) => { const rel = relative(FIXTURE_SOURCE, source); if (!rel) return true; const parts = rel.split('/'); if (parts.includes('.build') || parts.some((part) => part.endsWith('.xcodeproj'))) return false; // The checked-in fixture intentionally belongs to a different test // lane and contains a historical team ID. Never copy that signing // choice into this harness; generate a team-neutral spec below. if (rel === 'project.yml') return false; return true; }, }); return workspace; } catch (error) { rmSync(workspace, { recursive: true, force: true }); throw new HarnessError( 'fixture_copy_failed', 'product_failure', 'fixture_copy', 'Could not copy the iOS fixture into an isolated temporary workspace', ['Check that test/fixtures/ios-qa/FixtureApp is complete and readable.'], error instanceof Error ? error.message : String(error), ); } } function generateProject(workspace: string, includeDebugBridge: boolean): void { writeFileSync(join(workspace, 'project.yml'), renderProjectSpec(includeDebugBridge), 'utf8'); rmSync(join(workspace, 'FixtureApp.xcodeproj'), { recursive: true, force: true }); const generated = runCommand('xcodegen', [ 'generate', '--spec', join(workspace, 'project.yml'), '--project', workspace, '--quiet', ], { cwd: workspace, timeoutMs: 60_000 }); if (generated.status !== 0 || !existsSync(join(workspace, 'FixtureApp.xcodeproj'))) { throw new HarnessError( 'xcodegen_failed', 'product_failure', 'project_generation', 'xcodegen could not generate the temporary FixtureApp project', ['Run `xcodegen generate --spec project.yml` in a copy of the fixture and inspect the error.'], commandDetail(generated), ); } } function sha256File(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } export function redactDeviceForEvidence(device: PhysicalDevice): HarnessEvidence['device'] { // Evidence may be committed. Never persist the stable hardware UDID, the // CoreDevice UUID, or the user-assigned device name. A one-way fingerprint // still lets two evidence files prove they exercised the same device. const identifierSha256 = createHash('sha256') .update(`${device.hardwareUdid ?? 'no-hardware-udid'}\0${device.coreDeviceIdentifier}`) .digest('hex'); return { identifierSha256, model: device.model, platform: device.platform, transportType: device.transportType, pairingState: device.pairingState, developerModeStatus: device.developerModeStatus, }; } function walkFiles(root: string): string[] { const files: string[] = []; for (const entry of readdirSync(root)) { const path = join(root, entry); if (statSync(path).isDirectory()) files.push(...walkFiles(path)); else files.push(path); } return files; } function buildAndVerifyRelease(workspace: string): { executableSha256: string } { generateProject(workspace, false); const derivedData = join(workspace, 'DerivedData-Release'); const built = runCommand('xcodebuild', [ '-project', join(workspace, 'FixtureApp.xcodeproj'), '-scheme', 'FixtureApp', '-configuration', 'Release', '-destination', 'generic/platform=iOS', '-derivedDataPath', derivedData, 'CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO', 'CODE_SIGN_IDENTITY=', 'build', ], { cwd: workspace, timeoutMs: 10 * 60_000 }); if (built.status !== 0) { throw new HarnessError( 'release_build_failed', 'product_failure', 'release_guard', 'The unsigned Release fixture build failed', ['Fix the Release compilation error before attempting a Debug device deployment.'], commandDetail(built), ); } const app = join(derivedData, 'Build', 'Products', 'Release-iphoneos', 'FixtureApp.app'); const executable = join(app, 'FixtureApp'); if (!existsSync(executable)) { throw new HarnessError( 'release_app_missing', 'product_failure', 'release_guard', 'xcodebuild succeeded but the Release FixtureApp executable is missing', ['Inspect the Release build products under the temporary DerivedData directory.'], ); } const nm = runCommand('/usr/bin/nm', ['-gjU', executable], { timeoutMs: 30_000 }); const strings = runCommand('/usr/bin/strings', ['-a', executable], { timeoutMs: 30_000 }); if (nm.status !== 0 || strings.status !== 0) { throw new HarnessError( 'release_symbol_scan_failed', 'product_failure', 'release_guard', 'The Release build succeeded but its symbol/string scan did not complete', ['Verify `/usr/bin/nm` and `/usr/bin/strings` can inspect the FixtureApp executable, then rerun.'], `nm: ${commandDetail(nm, 20)}\nstrings: ${commandDetail(strings, 20)}`, ); } const bundlePaths = walkFiles(app).map((path) => relative(app, path)); const scan = `${nm.stdout}\n${nm.stderr}\n${strings.stdout}\n${bundlePaths.join('\n')}`; // The fixture deliberately renders the human-facing text "StateServer // should be on :9999" in both configurations, so the generic word // StateServer is not a linkage signal. Product/module names and the private // bootstrap log marker are. const forbidden = scan.match(/DebugBridge(?:Core|UI|Touch)?|gstack-ios-qa-bootstrap/gi) ?? []; if (forbidden.length > 0) { throw new HarnessError( 'release_debugbridge_leak', 'product_failure', 'release_guard', 'Release output contains DebugBridge symbols or artifacts', ['Keep DebugBridge package linkage and imports Debug-only, rebuild Release, and rerun the symbol scan.'], `forbidden markers: ${[...new Set(forbidden)].join(', ')}`, ); } return { executableSha256: sha256File(executable) }; } const SIGNING_FAILURE = /requires a development team|No Accounts|No signing certificate|No profiles for|provisioning profile|Apple ID account|not logged in|Developer Mode.*disabled|register.*device|communication with Apple failed/i; export function classifyXcodebuildFailure(output: string): 'signing_unavailable' | 'build_failed' { return SIGNING_FAILURE.test(output) ? 'signing_unavailable' : 'build_failed'; } function buildSignedDebug( workspace: string, device: PhysicalDevice, teamId?: string, ): string { generateProject(workspace, true); const derivedData = join(workspace, 'DerivedData-Debug'); const destinationId = device.hardwareUdid ?? device.coreDeviceIdentifier; const args = [ '-project', join(workspace, 'FixtureApp.xcodeproj'), '-scheme', 'FixtureApp', '-configuration', 'Debug', '-destination', `platform=iOS,id=${destinationId}`, '-derivedDataPath', derivedData, '-allowProvisioningUpdates', '-allowProvisioningDeviceRegistration', 'CODE_SIGN_STYLE=Automatic', `PRODUCT_BUNDLE_IDENTIFIER=${PHYSICAL_DEVICE_BUNDLE_ID}`, ]; if (teamId) args.push(`DEVELOPMENT_TEAM=${teamId}`); args.push('build'); const built = runCommand('xcodebuild', args, { cwd: workspace, timeoutMs: 15 * 60_000 }); if (built.status !== 0) { const detail = commandDetail(built); if (classifyXcodebuildFailure(detail) === 'signing_unavailable') { throw new HarnessError( 'signing_unavailable', 'setup_gate', 'debug_signing', 'Automatic signing or provisioning is not available for this Xcode installation', [ 'Open Xcode > Settings > Accounts, add the Apple ID that owns the development team, and create/download an Apple Development certificate.', `Optionally set ${TEAM_ID_ENV}= to select that signed-in team explicitly; the harness never hardcodes an account or team.`, 'Leave the iPhone connected and unlocked so Xcode can register it, then rerun the deploy harness.', ], detail, ); } throw new HarnessError( 'debug_build_failed', 'product_failure', 'debug_build', 'The Debug FixtureApp build failed for the selected iPhone', ['Fix the compiler/linker error, then rerun the same physical-device harness.'], detail, ); } const app = join(derivedData, 'Build', 'Products', 'Debug-iphoneos', 'FixtureApp.app'); if (!existsSync(join(app, 'FixtureApp'))) { throw new HarnessError( 'debug_app_missing', 'product_failure', 'debug_build', 'xcodebuild succeeded but the signed Debug FixtureApp bundle is missing', ['Inspect the Debug-iphoneos build products and confirm the FixtureApp scheme builds an application.'], ); } return app; } function installFixture(device: PhysicalDevice, appPath: string): void { const installed = runCommand('xcrun', [ 'devicectl', 'device', 'install', 'app', '--device', device.coreDeviceIdentifier, appPath, ], { timeoutMs: 120_000 }); if (installed.status !== 0) { throw new HarnessError( 'install_failed', 'product_failure', 'install', 'devicectl could not install the signed FixtureApp', ['Keep the iPhone unlocked and verify the provisioning profile includes this hardware UDID.'], commandDetail(installed), ); } } function launchFixture(device: PhysicalDevice): void { const launched = runCommand('xcrun', [ 'devicectl', 'device', 'process', 'launch', '--device', device.coreDeviceIdentifier, '--terminate-existing', '--activate', PHYSICAL_DEVICE_BUNDLE_ID, ], { timeoutMs: 60_000 }); if (launched.status !== 0) { const detail = commandDetail(launched); const locked = /not.*unlocked|device.*locked/i.test(detail); throw new HarnessError( locked ? 'device_locked' : 'launch_failed', locked ? 'setup_gate' : 'product_failure', 'launch', locked ? 'The iPhone must be unlocked before FixtureApp can launch' : 'devicectl could not launch FixtureApp', locked ? ['Unlock the iPhone, leave it on the Home Screen, and rerun the harness.'] : ['Inspect the install and launch diagnostics; do not uninstall or erase app data.'], detail, ); } } async function captureBootToken(device: PhysicalDevice, timeoutMs = 15_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const token = copyFileFromAppContainer({ udid: device.coreDeviceIdentifier, bundleId: PHYSICAL_DEVICE_BUNDLE_ID, sourceRelativePath: BOOT_TOKEN_PATH, }); if (token) return token; await delay(250); } throw new HarnessError( 'boot_token_unavailable', 'product_failure', 'bootstrap', 'FixtureApp launched but did not publish its short-lived bootstrap token', ['Inspect the app launch logs and StateServer startup; do not substitute a fabricated token.'], ); } function bootstrapError(error: BootstrapErrorReason, detail?: string): HarnessError { const setupErrors = new Set([ 'device_discovery_unavailable', 'device_discovery_failed', 'device_discovery_bad_response', 'no_devices', 'no_paired_device', 'device_not_found', 'device_locked', 'resolve_failed', ]); if (error === 'resolve_failed') { return new HarnessError( 'coredevice_tunnel_unavailable', 'setup_gate', 'bootstrap', 'CoreDevice did not expose a routable IPv6 tunnel for the iPhone', [ 'Keep the iPhone unlocked, reconnect USB, and run `xcrun devicectl device info details --device `.', 'Retry after the JSON shows connectionProperties.tunnelIPAddress.', ], detail, ); } return new HarnessError( 'bootstrap_failed', setupErrors.has(error) ? 'setup_gate' : 'product_failure', 'bootstrap', `The existing daemon bootstrap failed: ${error}`, setupErrors.has(error) ? ['Repair the reported CoreDevice setup gate and rerun the same harness.'] : ['Inspect StateServer startup/token rotation; do not mark the device run as passed.'], detail, ); } async function deviceRequest( tunnel: DeviceTunnel, path: string, options: { method?: string; token?: string | null; sessionId?: string; expectedBundle?: string; body?: Record; timeoutMs?: number; } = {}, ): Promise { const host = tunnel.ipv6Addr.includes(':') ? `[${tunnel.ipv6Addr}]` : tunnel.ipv6Addr; const token = options.token === undefined ? tunnel.bootTokenRotated : options.token; const headers: Record = { 'content-type': 'application/json' }; if (token) headers.authorization = `Bearer ${token}`; if (options.sessionId) headers['x-session-id'] = options.sessionId; if (options.expectedBundle) headers['x-gstack-expected-bundle-id'] = options.expectedBundle; try { const response = await fetch(`http://${host}:${tunnel.port}${path}`, { method: options.method ?? 'GET', headers, body: options.body ? JSON.stringify(options.body) : undefined, signal: AbortSignal.timeout(options.timeoutMs ?? 10_000), }); const text = await response.text(); let body: Record = {}; try { const parsed = JSON.parse(text); body = objectRecord(parsed) ?? { value: parsed }; } catch { body = { raw: text }; } return { status: response.status, body }; } catch (error) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'live_request', `${options.method ?? 'GET'} ${path} could not reach StateServer`, ['Keep the iPhone foregrounded and connected; inspect the CoreDevice tunnel and StateServer.'], error instanceof Error ? error.message : String(error), ); } } function requireStatus(response: ApiResponse, expected: number, label: string): void { if (response.status !== expected) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'live_checks', `${label} returned HTTP ${response.status}; expected ${expected}`, ['Treat this as a product failure unless the response identifies a setup gate.'], JSON.stringify(response.body), ); } } function summarizePng(base64: unknown): PngSummary { if (typeof base64 !== 'string' || base64.length === 0) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'screenshot', 'StateServer returned no PNG payload', ['Verify DebugBridgeUIWiring.installAll() ran in the Debug app.'], ); } const png = Buffer.from(base64, 'base64'); if (png.length < 24 || png.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') { throw new HarnessError( 'live_checks_failed', 'product_failure', 'screenshot', 'The screenshot payload is not a valid PNG', ['Inspect ScreenshotBridge.capturePNG() on the foreground app.'], ); } return { sha256: createHash('sha256').update(png).digest('hex'), bytes: png.length, width: png.readUInt32BE(16), height: png.readUInt32BE(20), }; } function parseFixtureElements(body: Record): FixtureElement[] { if (!Array.isArray(body.elements)) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'elements', 'StateServer response is missing elements[]', ['Verify the DebugBridgeUI elements resolver is installed.'], ); } return body.elements.flatMap((raw): FixtureElement[] => { const element = objectRecord(raw); const frame = objectRecord(element?.frame); const x = Number(frame?.x); const y = Number(frame?.y); const w = Number(frame?.w); const h = Number(frame?.h); if (![x, y, w, h].every(Number.isFinite)) return []; return [{ identifier: stringValue(element?.identifier), label: stringValue(element?.label), frame: { x, y, w, h }, }]; }); } function findTapButton(elements: FixtureElement[]): FixtureElement { const candidates = elements.filter((element) => element.frame.w > 0 && element.frame.h > 0 && (element.identifier === 'tap-button' || /^Tap \(\d+\)$/.test(element.label))); const button = candidates.find((element) => element.identifier === 'tap-button') ?? candidates[0]; if (!button) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'elements', 'The live accessibility tree does not contain the fixture tap button', ['Keep FixtureApp foregrounded and inspect /elements for tap-button.'], ); } return button; } function tapCount(label: string): number | null { const match = label.match(/^Tap \((\d+)\)$/); return match ? Number.parseInt(match[1]!, 10) : null; } function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; const record = objectRecord(value); if (record) { return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`; } return JSON.stringify(value); } function validateSnapshot(response: ApiResponse): Record { requireStatus(response, 200, 'state snapshot'); if (typeof response.body._schema_version !== 'number' || !objectRecord(response.body.keys)) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'state_snapshot', 'StateServer returned a malformed state snapshot envelope', ['Fix the snapshot schema before trusting cleanup evidence.'], JSON.stringify(response.body), ); } return response.body; } async function runLiveIteration( iteration: number, tunnel: DeviceTunnel, originalBootToken: string, ): Promise { let sessionId: string | undefined; let released = false; let primaryError: unknown; try { const healthBefore = await deviceRequest(tunnel, '/healthz', { token: null }); requireStatus(healthBefore, 200, 'health before tap'); if (healthBefore.body.bundle_id !== PHYSICAL_DEVICE_BUNDLE_ID) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'health_bundle', 'StateServer health identifies a different active bundle', ['Stop the run; never send coordinates when the active bundle does not match the fixture.'], JSON.stringify(healthBefore.body), ); } const deadBootToken = await deviceRequest(tunnel, '/auth/rotate', { method: 'POST', token: originalBootToken, body: { new_token: `must-not-activate-${randomUUID()}` }, }); requireStatus(deadBootToken, 401, 'original boot token reuse'); if (deadBootToken.body.error !== 'boot_token_invalid') { throw new HarnessError( 'live_checks_failed', 'product_failure', 'token_rotation', 'The original bootstrap credential was rejected for an unexpected reason', ['Inspect StateServer auth rotation and do not accept ambiguous token evidence.'], JSON.stringify(deadBootToken.body), ); } const acquired = await deviceRequest(tunnel, '/session/acquire', { method: 'POST' }); requireStatus(acquired, 200, 'session acquire'); sessionId = stringValue(acquired.body.session_id); if (!sessionId) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'session_acquire', 'StateServer did not issue a session ID', ['Fix the device-lock response before allowing coordinate mutations.'], ); } const snapshotBefore = validateSnapshot(await deviceRequest(tunnel, '/state/snapshot')); const screenshotBeforeResponse = await deviceRequest(tunnel, '/screenshot'); requireStatus(screenshotBeforeResponse, 200, 'screenshot before tap'); const screenshotBefore = summarizePng(screenshotBeforeResponse.body.png_base64); const elementsBeforeResponse = await deviceRequest(tunnel, '/elements'); requireStatus(elementsBeforeResponse, 200, 'elements before tap'); const elementsBefore = parseFixtureElements(elementsBeforeResponse.body); const buttonBefore = findTapButton(elementsBefore); const countBefore = tapCount(buttonBefore.label); if (countBefore === null) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'elements', `The fixture button label is not count-bearing: ${buttonBefore.label}`, ['Keep the fixture UI contract as `Tap ()` for observable tap verification.'], ); } const tapped = await deviceRequest(tunnel, '/tap', { method: 'POST', sessionId, expectedBundle: PHYSICAL_DEVICE_BUNDLE_ID, body: { x: buttonBefore.frame.x + buttonBefore.frame.w / 2, y: buttonBefore.frame.y + buttonBefore.frame.h / 2, }, }); requireStatus(tapped, 200, 'coordinate tap'); if ( tapped.body.ok !== true || tapped.body.active_bundle_before !== PHYSICAL_DEVICE_BUNDLE_ID || tapped.body.active_bundle_after !== PHYSICAL_DEVICE_BUNDLE_ID ) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'coordinate_tap', 'The coordinate tap did not preserve and report the expected active bundle', ['Treat any active-bundle mismatch as a hard safety failure.'], JSON.stringify(tapped.body), ); } let screenshotAfter: PngSummary | null = null; let elementsAfter: FixtureElement[] = []; let buttonAfter: FixtureElement | null = null; const updateDeadline = Date.now() + 4_000; while (Date.now() < updateDeadline) { await delay(200); const elementsResponse = await deviceRequest(tunnel, '/elements'); requireStatus(elementsResponse, 200, 'elements after tap'); elementsAfter = parseFixtureElements(elementsResponse.body); buttonAfter = findTapButton(elementsAfter); const screenshotResponse = await deviceRequest(tunnel, '/screenshot'); requireStatus(screenshotResponse, 200, 'screenshot after tap'); screenshotAfter = summarizePng(screenshotResponse.body.png_base64); if ( tapCount(buttonAfter.label) === countBefore + 1 && screenshotAfter.sha256 !== screenshotBefore.sha256 ) break; } if ( !buttonAfter || !screenshotAfter || tapCount(buttonAfter.label) !== countBefore + 1 || screenshotAfter.sha256 === screenshotBefore.sha256 ) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'coordinate_tap', 'The tap returned success but the real UI did not advance visually', ['Inspect DebugBridgeTouch and the SwiftUI hit-test path on this iOS version.'], JSON.stringify({ before: buttonBefore.label, after: buttonAfter?.label ?? null }), ); } const healthAfter = await deviceRequest(tunnel, '/healthz', { token: null }); requireStatus(healthAfter, 200, 'health after tap'); if (healthAfter.body.bundle_id !== PHYSICAL_DEVICE_BUNDLE_ID) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'health_bundle', 'The active bundle changed after the coordinate tap', ['Stop the run and inspect foreground-app activation before any further mutation.'], JSON.stringify(healthAfter.body), ); } const snapshotAfter = validateSnapshot(await deviceRequest(tunnel, '/state/snapshot')); let stateCleanup: 'unchanged' | 'restored' = 'unchanged'; if (stableJson(snapshotAfter) !== stableJson(snapshotBefore)) { const restored = await deviceRequest(tunnel, '/state/restore', { method: 'POST', sessionId, body: snapshotBefore, }); requireStatus(restored, 200, 'state restore cleanup'); const snapshotClean = validateSnapshot(await deviceRequest(tunnel, '/state/snapshot')); if (stableJson(snapshotClean) !== stableJson(snapshotBefore)) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'state_cleanup', 'State restore returned success but did not restore the captured snapshot', ['Fix atomic state restore before claiming cleanup succeeded.'], ); } stateCleanup = 'restored'; } const releasedResponse = await deviceRequest(tunnel, '/session/release', { method: 'POST', sessionId, }); requireStatus(releasedResponse, 200, 'session release'); released = true; return { iteration, passed: true, checks: { health_bundle: { passed: true, bundleBefore: String(healthBefore.body.bundle_id), bundleAfter: String(healthAfter.body.bundle_id), }, token_rotation: { passed: true, originalBootTokenRejected: true, }, session_acquire: { passed: true, sessionIdIssued: true, released: true, }, screenshot_elements: { passed: true, elementCountBefore: elementsBefore.length, elementCountAfter: elementsAfter.length, screenshotBefore, screenshotAfter, }, coordinate_tap_state_cleanup: { passed: true, buttonLabelBefore: buttonBefore.label, buttonLabelAfter: buttonAfter.label, activeBundleBefore: String(tapped.body.active_bundle_before), activeBundleAfter: String(tapped.body.active_bundle_after), stateCleanup, }, }, }; } catch (error) { primaryError = error; throw error; } finally { if (sessionId && !released) { try { const cleanupRelease = await deviceRequest(tunnel, '/session/release', { method: 'POST', sessionId }); requireStatus(cleanupRelease, 200, 'failed-iteration session cleanup'); } catch (cleanupError) { if (primaryError) { throw new AggregateError( [primaryError, cleanupError], 'Live iteration failed and its session cleanup also failed', { cause: primaryError }, ); } throw cleanupError; } } } } function serializeUnknownError(error: unknown): Record { if (error instanceof HarnessError) return error.toJSON(); if (error instanceof AggregateError) { return { name: error.name, message: error.message, errors: [...error.errors].map((nested) => serializeUnknownError(nested)), }; } if (error instanceof Error) return { name: error.name, message: error.message }; return { message: String(error) }; } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function runFiveLiveIterations( tunnel: DeviceTunnel, originalBootToken: string, ): Promise { // Clear any lock left by startup probing. This bundle was just installed and // relaunched by this harness, so no unrelated app/session is in scope. const initialRelease = await deviceRequest(tunnel, '/session/release', { method: 'POST' }); requireStatus(initialRelease, 200, 'initial session cleanup'); const results: Array = []; for (let iteration = 1; iteration <= REQUIRED_LIVE_ITERATIONS; iteration++) { try { results.push(await runLiveIteration(iteration, tunnel, originalBootToken)); } catch (error) { results.push({ iteration, passed: false, error: serializeUnknownError(error) }); } } const failed = results.filter((result): result is FailedLiveIteration => !result.passed); if (failed.length > 0) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'live_checks', `${failed.length} of ${REQUIRED_LIVE_ITERATIONS} live iterations failed`, ['Fix the first failing check, then rerun all five iterations; partial passes do not count.'], JSON.stringify(results), ); } return results as LiveIterationResult[]; } function writePassingEvidence(evidence: HarnessEvidence): string { if ( evidence.passedIterations !== REQUIRED_LIVE_ITERATIONS || evidence.iterations.length !== REQUIRED_LIVE_ITERATIONS || !evidence.iterations.every((iteration) => iteration.passed) ) { throw new HarnessError( 'live_checks_failed', 'product_failure', 'evidence', 'Refusing to write evidence for an incomplete device run', ['Evidence is written only after a real 5/5 pass.'], ); } mkdirSync(EVIDENCE_DIR, { recursive: true }); const stamp = evidence.generatedAt.replace(/[:.]/g, '-'); const destination = join(EVIDENCE_DIR, `ios-physical-device-${stamp}.json`); const temporary = `${destination}.tmp-${process.pid}`; try { writeFileSync(temporary, `${JSON.stringify(evidence, null, 2)}\n`, { encoding: 'utf8', mode: 0o644 }); renameSync(temporary, destination); } catch (error) { rmSync(temporary, { force: true }); throw new HarnessError( 'cleanup_failed', 'product_failure', 'evidence', 'The live run passed, but its evidence file could not be written atomically', ['Fix permissions on docs/gstack-2/evidence and rerun the complete 5/5 lane.'], error instanceof Error ? error.message : String(error), ); } return destination; } interface CliOptions { preflightOnly: boolean; json: boolean; selector?: string; } function parseArguments(args: string[]): CliOptions { const options: CliOptions = { preflightOnly: false, json: false }; for (let index = 0; index < args.length; index++) { const arg = args[index]!; if (arg === '--preflight-only') options.preflightOnly = true; else if (arg === '--json') options.json = true; else if (arg === '--device') { const value = args[++index]; if (!value) { throw new HarnessError( 'invalid_arguments', 'setup_gate', 'arguments', '--device requires a hardware UDID or CoreDevice UUID', ); } options.selector = value; } else if (arg === '--help' || arg === '-h') { process.stdout.write([ 'Usage: bun run ios-qa/scripts/physical-device-smoke.ts [options]', '', ' --preflight-only Check Xcode, devicectl, pairing/trust, Developer Mode, and DevToolsSecurity.', ' --device Select by hardware UDID or CoreDevice UUID.', ' --json Print the success result as JSON.', '', `Optional signing team: ${TEAM_ID_ENV}=`, `Conflict replacement opt-in: ${REPLACE_CONFLICT_ENV}=1`, '', ].join('\n')); process.exit(0); } else { throw new HarnessError( 'invalid_arguments', 'setup_gate', 'arguments', `Unknown argument: ${arg}`, ['Run with --help for supported options.'], ); } } return options; } export async function runPhysicalDeviceHarness(options: CliOptions): Promise<{ evidence: HarnessEvidence; evidencePath: string; }> { const toolchain = runToolchainPreflight(); const devices = discoverPhysicalDevices(); const selector = options.selector ?? process.env.GSTACK_IOS_TARGET_UDID?.trim(); const device = selectPhysicalDevice(devices, selector); verifyDeviceGates(device); const teamId = resolveTeamId(); if (options.preflightOnly) { throw new HarnessError( 'invalid_arguments', 'setup_gate', 'arguments', 'runPhysicalDeviceHarness cannot be called with preflightOnly; use runPreflightOnly instead', ); } const existingBundle = checkInstallSafety( device, process.env[REPLACE_CONFLICT_ENV] === '1', ); const workspace = copyFixtureToTemporaryWorkspace(); let keepalive: { stop: () => void } | null = null; let activeTunnel: DeviceTunnel | null = null; let originalBootToken = ''; let finalSessionCleanupSucceeded = false; let evidenceWithoutPath: HarnessEvidence | null = null; let primaryError: unknown = null; try { const release = buildAndVerifyRelease(workspace); const debugApp = buildSignedDebug(workspace, device, teamId); installFixture(device, debugApp); launchFixture(device); originalBootToken = await captureBootToken(device); const bootstrapped = await bootstrapTunnel({ udid: device.coreDeviceIdentifier, bundleId: PHYSICAL_DEVICE_BUNDLE_ID, bootTokenPath: BOOT_TOKEN_PATH, startupTimeoutMs: 20_000, }); if (!bootstrapped.ok) throw bootstrapError(bootstrapped.error, bootstrapped.detail); activeTunnel = bootstrapped.tunnel; keepalive = startTunnelKeepalive(bootstrapped.tunnel.udid); const iterations = await runFiveLiveIterations(bootstrapped.tunnel, originalBootToken); evidenceWithoutPath = { schemaVersion: 1, kind: 'gstack-ios-qa-physical-device', passed: true, generatedAt: new Date().toISOString(), requiredIterations: 5, passedIterations: 5, toolchain, device: redactDeviceForEvidence(device), bundleId: PHYSICAL_DEVICE_BUNDLE_ID, signing: { automatic: true, explicitTeamFromEnvironment: Boolean(teamId), }, installSafety: { existingBundle, appDataDeleted: false, appUninstalled: false, }, releaseGuard: { built: true, debugBridgeSymbolsAbsent: true, executableSha256: release.executableSha256, }, bootstrap: { transport: 'CoreDevice IPv6', daemonBootstrap: true, tokenRotated: true, stateServerPort: bootstrapped.tunnel.port, }, iterations, cleanup: { sessionsReleased: true, tunnelKeepaliveStopped: true, temporaryWorkspaceRemoved: true, }, }; } catch (error) { primaryError = error; } finally { originalBootToken = ''; if (activeTunnel) { try { const finalRelease = await deviceRequest(activeTunnel, '/session/release', { method: 'POST' }); requireStatus(finalRelease, 200, 'final session cleanup'); finalSessionCleanupSucceeded = true; } catch (error) { if (!primaryError) { primaryError = new HarnessError( 'cleanup_failed', 'product_failure', 'cleanup', 'The final StateServer session release failed', ['Reconnect the fixture and release its session before rerunning; do not delete app data.'], error instanceof Error ? error.message : String(error), ); } } } try { keepalive?.stop(); keepalive = null; rmSync(workspace, { recursive: true, force: true }); } catch (error) { if (!primaryError) { primaryError = new HarnessError( 'cleanup_failed', 'product_failure', 'cleanup', 'The temporary workspace or tunnel keepalive could not be cleaned up', ['Remove only the reported temporary gstack workspace after inspecting it; never erase device app data.'], error instanceof Error ? error.message : String(error), ); } } } if (primaryError) throw primaryError; if (!evidenceWithoutPath || !finalSessionCleanupSucceeded) { throw new HarnessError( 'cleanup_failed', 'product_failure', 'cleanup', 'The harness finished without verified session cleanup', ['Do not write pass evidence until session cleanup succeeds.'], ); } const evidencePath = writePassingEvidence(evidenceWithoutPath); return { evidence: evidenceWithoutPath, evidencePath }; } export function runPreflightOnly(options: Pick): { ok: true; mode: 'preflight-only'; toolchain: ToolchainPreflight; device: PhysicalDevice; acceptedIdentifiers: { hardwareUdid: string | null; coreDeviceIdentifier: string }; } { const toolchain = runToolchainPreflight(); const devices = discoverPhysicalDevices(); const selector = options.selector ?? process.env.GSTACK_IOS_TARGET_UDID?.trim(); const device = selectPhysicalDevice(devices, selector); verifyDeviceGates(device); resolveTeamId(); return { ok: true, mode: 'preflight-only', toolchain, device, acceptedIdentifiers: { hardwareUdid: device.hardwareUdid, coreDeviceIdentifier: device.coreDeviceIdentifier, }, }; } function reportFailure(error: unknown): void { const failure = error instanceof HarnessError ? error : new HarnessError( 'live_checks_failed', 'product_failure', 'unknown', error instanceof Error ? error.message : String(error), ); process.stderr.write(`GSTACK_IOS_PHYSICAL_DEVICE_ERROR ${JSON.stringify(failure.toJSON())}\n`); for (const remediation of failure.remediation) { process.stderr.write(`REMEDIATION: ${remediation}\n`); } process.exitCode = failure.category === 'product_failure' ? 1 : failure.category === 'setup_gate' ? 2 : 3; } if (import.meta.main) { try { const options = parseArguments(process.argv.slice(2)); if (options.preflightOnly) { const result = runPreflightOnly(options); process.stdout.write(`${JSON.stringify(result, null, options.json ? 2 : 0)}\n`); } else { const result = await runPhysicalDeviceHarness(options); const printable = { ok: true, passedIterations: result.evidence.passedIterations, requiredIterations: result.evidence.requiredIterations, evidencePath: result.evidencePath, }; process.stdout.write(`${JSON.stringify(options.json ? { ...printable, evidence: result.evidence } : printable, null, options.json ? 2 : 0)}\n`); } } catch (error) { reportFailure(error); } }