From 4ad7a98b43d6410239b909930125b3e143e41bd8 Mon Sep 17 00:00:00 2001 From: BK Date: Sun, 6 Sep 2026 19:31:43 +0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(apple):=20headless=20simulator=20boot?= =?UTF-8?q?=20=E2=80=94=20bootTargetHeadless=20cell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple simulators join Android emulators in the boot --headless admission: cold-booting through the daemon (boot --headless / programmatic DeviceBootOptions.headless) now skips 'open -a Simulator', so automation surfaces boot devices without splashing a GUI window over the desktop. - readiness: AppleReadinessOptions.headless gates showSimulator() on the cold-boot path only (warm boots never opened it; default unchanged) - runtime: bootTargetHeadless admitted where boot is (iOS/iPadOS/tvOS/ visionOS simulators; macOS has no window, watchOS cannot boot) via module-level appleBootHeadlessFact — kept inspectFacts under the fallow complexity threshold - daemon: stale 'Android only' rejection message now names both platforms - tests: runtime-headless (cold GUI open / headless skip / warm never), leaf table gains the headless column, harness fact admits Apple sims, the old non-Android rejection test inverted into the admission test --- .../src/readiness/runtime-headless.test.ts | 84 ++++++++++ .../platform-apple/src/readiness/runtime.ts | 7 +- .../__tests__/runner-command-retry.test.ts | 4 +- .../src/runner/runner-contract.ts | 5 +- packages/platform-apple/src/runtime.test.ts | 145 +++++++++--------- packages/platform-apple/src/runtime.ts | 24 ++- .../__tests__/session-boot-shutdown.test.ts | 12 +- .../__tests__/session-command-harness.ts | 4 +- .../handlers/__tests__/session-state.test.ts | 2 +- src/daemon/handlers/session-state.ts | 2 +- 10 files changed, 202 insertions(+), 87 deletions(-) create mode 100644 packages/platform-apple/src/readiness/runtime-headless.test.ts diff --git a/packages/platform-apple/src/readiness/runtime-headless.test.ts b/packages/platform-apple/src/readiness/runtime-headless.test.ts new file mode 100644 index 0000000000..c62dc4bf54 --- /dev/null +++ b/packages/platform-apple/src/readiness/runtime-headless.test.ts @@ -0,0 +1,84 @@ +import { expect, test, vi } from 'vitest'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { platformRuntimeHostFixture } from '../runtime.fixtures.ts'; +import { ensureAppleReady } from './runtime.ts'; + +type CommandCall = { executable: string; args: readonly string[] }; + +/** A host whose simctl reports Shutdown until the boot command runs, then + * Booted (boot + bootstatus succeed); records every host.commands.run call + * (the `open -a Simulator` surface). */ +function coldBootHost(bootedFromStart = false): { + host: PlatformRuntimeHost; + commandCalls: CommandCall[]; +} { + const commandCalls: CommandCall[] = []; + let booted = bootedFromStart; + const host: PlatformRuntimeHost = { + ...platformRuntimeHostFixture(), + appleTools: { + ...platformRuntimeHostFixture().appleTools, + run: vi.fn(async (request: { args: readonly string[] }) => { + if (request.args.includes('boot')) booted = true; + return { + stdout: JSON.stringify({ + devices: { ios: [{ udid: 'sim-1', state: booted ? 'Booted' : 'Shutdown' }] }, + }), + stderr: '', + exitCode: 0, + }; + }), + }, + commands: { + ...platformRuntimeHostFixture().commands, + run: async (request: { executable: string; args: readonly string[] }) => { + commandCalls.push({ executable: request.executable, args: request.args }); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + } as PlatformRuntimeHost['commands'], + }; + return { host, commandCalls }; +} + +function simulatorOpenedSimulator(calls: readonly CommandCall[]): boolean { + return calls.some((c) => c.executable === 'open' && c.args.includes('Simulator')); +} + +test('cold boot without headless opens the Simulator GUI app', async () => { + const { host, commandCalls } = coldBootHost(); + await ensureAppleReady(host, simulatorShutdown(), new AbortController().signal); + expect(simulatorOpenedSimulator(commandCalls)).toBe(true); +}); + +test('cold boot with headless skips the Simulator GUI app', async () => { + const { host, commandCalls } = coldBootHost(); + await ensureAppleReady(host, simulatorShutdown(), new AbortController().signal, { + headless: true, + }); + expect(simulatorOpenedSimulator(commandCalls)).toBe(false); +}); + +test('warm boot never opens the Simulator GUI app regardless of headless', async () => { + const { host, commandCalls } = coldBootHost(true); + await ensureAppleReady(host, simulator({ booted: true }), new AbortController().signal, { + headless: true, + }); + await ensureAppleReady(host, simulator({ booted: true }), new AbortController().signal); + expect(commandCalls).toHaveLength(0); +}); + +function simulator(overrides: Partial = {}): DeviceInfo { + return { + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + id: 'sim-1', + name: 'iPhone 16', + ...overrides, + } as DeviceInfo; +} + +function simulatorShutdown(): DeviceInfo { + return simulator({ booted: false }); +} diff --git a/packages/platform-apple/src/readiness/runtime.ts b/packages/platform-apple/src/readiness/runtime.ts index fe0925fe0e..103855c2e8 100644 --- a/packages/platform-apple/src/readiness/runtime.ts +++ b/packages/platform-apple/src/readiness/runtime.ts @@ -18,6 +18,11 @@ export type AppleReadinessOptions = Readonly<{ * runner cache in parallel with the boot, which is the whole reason the hook exists. */ onColdBootStart?: () => void; + /** + * Cold-boot without focusing the Simulator GUI app. The default keeps the + * human-facing behavior; automation callers (daemon `boot --headless`) opt in. + */ + headless?: boolean; }>; export async function ensureAppleReady( @@ -40,7 +45,7 @@ export async function ensureAppleReady( options.onColdBootStart?.(); host.deviceReadiness.appleAutomation.keepHot(device); await bootSimulator(host, device, signal); - await showSimulator(host, signal); + if (!options.headless) await showSimulator(host, signal); } host.deviceReadiness.appleAutomation.keepHot(device); // Publish the fresh observation so the boot checks later in this flow skip their own listing. diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index f18f5f8942..d2ee7f59ec 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -1310,9 +1310,7 @@ test('runAppleRunnerCommand does NOT resend a mutating command on a generic retr new AppError('COMMAND_FAILED', 'fetch failed'), ); - await assert.rejects( - runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 1, y: 2 }), - ); + await assert.rejects(runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 1, y: 2 })); // Only ONE tap dispatch. (A status probe may follow as recovery — that is // not a resend; every tap-shaped call must be the original alone.) const tapDispatches = mockExecuteRunnerCommandWithSession.mock.calls.filter( diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 8c10a56170..50e9e27dd3 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -255,7 +255,10 @@ function matchesRunnerErrorRule(error: AppError, match: RunnerErrorMatch): boole function matchesRunnerErrorCode(error: AppError, runnerErrorCode: string | undefined): boolean { if (!runnerErrorCode) return true; - return (error.details as { runnerErrorCode?: unknown } | undefined)?.runnerErrorCode === runnerErrorCode; + return ( + (error.details as { runnerErrorCode?: unknown } | undefined)?.runnerErrorCode === + runnerErrorCode + ); } function matchesRunnerErrorDetails(error: AppError, details: RunnerErrorMatch['details']): boolean { diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index a2e59c7f0d..edbc0486e4 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -57,90 +57,95 @@ test('tvOS audio capture availability follows the exact host-owned runtime fact' }); test.each([ - ['iOS simulator', leaves.ios, true, undefined], + ['iOS simulator', leaves.ios, true, undefined, true], [ 'iOS physical CoreDevice', appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }), true, undefined, + false, ], [ 'iOS physical XCTest', appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'xctest' }), false, 'CoreDevice-backed physical iOS device', + false, ], - ['iPadOS simulator', leaves.ipados, true, undefined], - ['tvOS simulator', leaves.tvos, true, undefined], - ['macOS host', leaves.macos, true, undefined], - ['visionOS simulator', leaves.visionos, true, undefined], - ['watchOS sentinel', leaves.watchos, false, 'watchOS app logs are not supported'], -])('classifies the %s leaf explicitly', async (_name, device, available, hint) => { - const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ - device, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); - const { facts } = binding; - expect(facts.device.providerMode).toBe('local'); - expect(facts.operations.appState).toEqual({ - available: false, - reason: 'unsupported-platform-leaf', - hint: expect.stringContaining('session state'), - }); - expect(binding.operations.appState).toBeUndefined(); - expect(facts.operations.networkDump).toEqual({ available: true }); - expect(facts.operations.listApps.available).toBe( - device.appleOs !== 'watchos' && device.iosPhysicalDeviceBackend !== 'xctest', - ); - // R40/R41: touch and text ride the Apple interactor, which exists for the simulator and - // physical device kinds — every leaf in this table is one of those two, so both cells are - // available across it (parity with the retired buckets). - expect(facts.operations.focusPoint).toEqual({ available: true }); - expect(facts.operations.typeText).toEqual({ available: true }); - expect(binding.operations.focusPoint).toBeTypeOf('function'); - expect(binding.operations.typeText).toBeTypeOf('function'); - for (const operation of ['appLogInspect', 'appLogDoctor', 'appLogStart'] as const) { - const fact = facts.operations[operation]; - expect(fact.available).toBe(available); - if (!available && hint) expect(fact).toHaveProperty('hint', expect.stringContaining(hint)); - } - for (const operation of [ - 'screenRecordingStart', - 'screenRecordingReattach', - 'screenRecordingCleanup', - ] as const) { - expect(facts.operations[operation].available).toBe(available); - } - expectApplePerfAvailability(binding, available); - if (device.iosPhysicalDeviceBackend === 'xctest') { - expect(facts.operations.screenRecordingStart).toMatchObject({ - hint: expect.stringContaining('CoreDevice-backed physical iOS device'), + ['iPadOS simulator', leaves.ipados, true, undefined, true], + ['tvOS simulator', leaves.tvos, true, undefined, true], + ['macOS host', leaves.macos, true, undefined, false], + ['visionOS simulator', leaves.visionos, true, undefined, true], + ['watchOS sentinel', leaves.watchos, false, 'watchOS app logs are not supported', false], +])( + 'classifies the %s leaf explicitly', + async (_name, device, available, hint, bootHeadlessExpected) => { + const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, }); - } - if (device.appleOs === 'watchos') { - expect(facts.operations.screenRecordingStart).toMatchObject({ - hint: 'watchOS recording is not supported.', + const { facts } = binding; + expect(facts.device.providerMode).toBe('local'); + expect(facts.operations.appState).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + hint: expect.stringContaining('session state'), }); - } - expect(facts.operations.ensureReady.available).toBe(device.appleOs !== 'watchos'); - expect(facts.operations.bootTarget.available).toBe( - device.appleOs !== 'macos' && device.appleOs !== 'watchos', - ); - expect(facts.operations.bootTargetHeadless.available).toBe(false); - expect(facts.operations.setViewport).toEqual({ - available: false, - reason: 'unsupported-platform-leaf', - hint: 'viewport resizes web targets only (--platform web). Apple screen geometry is fixed by the selected simulator or device type — open a different simulator to test another screen size.', - }); - expect(binding.operations.setViewport).toBeUndefined(); - expectAppleCaptureAvailability(binding, device); - expectAppleSnapshotAvailability(binding, device); -}); + expect(binding.operations.appState).toBeUndefined(); + expect(facts.operations.networkDump).toEqual({ available: true }); + expect(facts.operations.listApps.available).toBe( + device.appleOs !== 'watchos' && device.iosPhysicalDeviceBackend !== 'xctest', + ); + // R40/R41: touch and text ride the Apple interactor, which exists for the simulator and + // physical device kinds — every leaf in this table is one of those two, so both cells are + // available across it (parity with the retired buckets). + expect(facts.operations.focusPoint).toEqual({ available: true }); + expect(facts.operations.typeText).toEqual({ available: true }); + expect(binding.operations.focusPoint).toBeTypeOf('function'); + expect(binding.operations.typeText).toBeTypeOf('function'); + for (const operation of ['appLogInspect', 'appLogDoctor', 'appLogStart'] as const) { + const fact = facts.operations[operation]; + expect(fact.available).toBe(available); + if (!available && hint) expect(fact).toHaveProperty('hint', expect.stringContaining(hint)); + } + for (const operation of [ + 'screenRecordingStart', + 'screenRecordingReattach', + 'screenRecordingCleanup', + ] as const) { + expect(facts.operations[operation].available).toBe(available); + } + expectApplePerfAvailability(binding, available); + if (device.iosPhysicalDeviceBackend === 'xctest') { + expect(facts.operations.screenRecordingStart).toMatchObject({ + hint: expect.stringContaining('CoreDevice-backed physical iOS device'), + }); + } + if (device.appleOs === 'watchos') { + expect(facts.operations.screenRecordingStart).toMatchObject({ + hint: 'watchOS recording is not supported.', + }); + } + expect(facts.operations.ensureReady.available).toBe(device.appleOs !== 'watchos'); + expect(facts.operations.bootTarget.available).toBe( + device.appleOs !== 'macos' && device.appleOs !== 'watchos', + ); + expect(facts.operations.bootTargetHeadless.available).toBe(bootHeadlessExpected); + expect(facts.operations.setViewport).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'viewport resizes web targets only (--platform web). Apple screen geometry is fixed by the selected simulator or device type — open a different simulator to test another screen size.', + }); + expect(binding.operations.setViewport).toBeUndefined(); + expectAppleCaptureAvailability(binding, device); + expectAppleSnapshotAvailability(binding, device); + }, +); function expectApplePerfAvailability( binding: DeviceBinding, diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 1ba6824045..4b8e8f6054 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -110,7 +110,7 @@ const appStateUnavailable = Object.freeze({ const headlessUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', - hint: 'Headless boot is supported only for local Android emulators.', + hint: 'Headless boot is supported only for local Android emulators and Apple simulators.', } as const); const elementTextLeafUnavailable = Object.freeze({ available: false, @@ -266,6 +266,15 @@ function appleFocusFact(device: DeviceInfo): RuntimeOperationFact { return device.kind === 'simulator' || device.kind === 'device' ? available : focusKindUnavailable; } +/** Headless boot rides the boot cell wherever the target has a GUI window to + * suppress: simulators only (macOS has no window; watchOS cannot boot at all). */ +function appleBootHeadlessFact( + device: DeviceInfo, + boot: RuntimeOperationFact, +): RuntimeOperationFact { + return device.kind === 'simulator' && boot.available ? boot : headlessUnavailable; +} + export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createAppleAppLogRuntime(host); const inspectFacts = async (device: DeviceInfo) => { @@ -285,6 +294,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR : leafRecordingFacts; const readiness = device.appleOs === 'watchos' ? unavailable : available; const boot = isMacOs(device) || device.appleOs === 'watchos' ? unavailable : available; + const bootHeadless = appleBootHeadlessFact(device, boot); const apps = appInventoryFacts(device); return Object.freeze({ device: logs.device, @@ -328,7 +338,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR ...perfRuntimeOperationFacts(applePerfFacts(device)), ensureReady: readiness, bootTarget: boot, - bootTargetHeadless: headlessUnavailable, + bootTargetHeadless: bootHeadless, listApps: apps, ...appleApplicationLifecycleFacts(device), shutdownTarget: shutdownFact(device), @@ -461,6 +471,16 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR bootTarget: async () => await ensureAppleReady(host, request.device, request.scope.signal), })), + ...(facts.operations.bootTargetHeadless.available + ? { + bootTargetHeadless: async () => + // Daemon input is serial/allowlist-only; headless is a caller-side + // mode of this cell, not request data (parity with Android). + await ensureAppleReady(host, request.device, request.scope.signal, { + headless: true, + }), + } + : {}), ...whenAdmitted(facts.operations.listApps, () => ({ listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => await host.appInventory.apple.listApps( diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts index 21c67ba9f6..6d4744f3dc 100644 --- a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts @@ -286,7 +286,7 @@ test('boot --headless requires avd selector when device cannot be resolved', asy } }); -test('boot --headless rejects non-Android selectors', async () => { +test('boot --headless admits an Apple simulator selector', async () => { const sessionStore = makeSessionStore(); mockResolveTargetDevice.mockResolvedValue({ platform: 'apple', @@ -312,12 +312,10 @@ test('boot --headless rejects non-Android selectors', async () => { }); expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/headless is supported only for Android emulators/i); - } + expect(response?.ok).toBe(true); + expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); + expect(mockEnsureReadyHeadlessRuntime).toHaveBeenCalledOnce(); + expect(mockEnsureReadyRuntime).not.toHaveBeenCalled(); }); test('boot keeps --target validation before facts inspection', async () => { diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index c9d2198580..b83a6fa185 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -122,7 +122,9 @@ export const readinessDeviceRuntimeGateway: DeviceRuntimeGateway { const normalAvailable = supportsReadiness(device); - const headlessAvailable = device.platform === 'android' && device.kind === 'emulator'; + const headlessAvailable = + (device.platform === 'android' && device.kind === 'emulator') || + (device.platform === 'apple' && device.kind === 'simulator'); const shutdownAvailable = isShutdownDevice(device); const deployment = deploymentAvailability(device); const base = createUnavailableRuntimeFactsForTest(device, localRuntimeOwner(device.platform)); diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 1c580330b1..435d4cfe78 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -19,7 +19,7 @@ import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/p import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; -test('boot rejects --headless outside Android directly', async () => { +test('boot rejects --headless when the runtime withholds the headless cell', async () => { const device = { platform: 'apple' as const, appleOs: 'ios' as const, diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index dc8987853a..7fa13006ab 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -42,7 +42,7 @@ function bootUnavailableResponse(headless: boolean): UnavailableRuntimeResponse errorResponse( headless ? 'INVALID_ARGS' : 'UNSUPPORTED_OPERATION', headless - ? 'boot --headless is supported only for Android emulators.' + ? 'boot --headless is supported only for Android emulators and Apple simulators.' : 'boot is not supported on this device', undefined, unavailable.hint ? { hint: unavailable.hint } : undefined, From e95cf4a28f4bde0e2555556d72f1309675a27cb7 Mon Sep 17 00:00:00 2001 From: BK Date: Fri, 25 Sep 2026 13:26:02 +0700 Subject: [PATCH 2/3] fix(apple): direct-launch opens terminate the running process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchArgs / launchConsole / clearAppState change the launch SHAPE — an open carrying any of them is definitionally a fresh launch, so the old process (launched under different args) must not survive. Previously terminateRunningApp was only set for relaunch:true opens; an args-carrying open against a live process made simctl launch silently no-op (rc=0, same pid — verified live) and the new args never applied. Symptom downstream: Crucible's -uitest-live-refresh launch arg was dropped whenever the app was already running, so the test seam never armed and world-swap refreshes failed SWAP_NOT_CONFIRMED. isDirectAppLaunch (already the direct-launch classifier) now also gates terminateRunningApp. Tests updated to the new invariant: the console-pty provider scenario expects --terminate-running-process; the relaunch+clearAppState daemon test expects terminateRunningApp true (belt-and-braces after its close-first). 2348/2348. --- packages/platform-apple/src/lifecycle.ts | 3 ++- src/daemon/handlers/__tests__/session-relaunch-close.test.ts | 4 +++- test/integration/provider-scenarios/ios-lifecycle.test.ts | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index ad03d7df9e..ac7b4a0244 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -196,7 +196,8 @@ async function dispatchAppleOpen( positionals: launch.positionals, appBundleId: input.appBundleId, execution: input.execution, - ...(localIosSimulator && input.relaunch && !input.execution.clearAppState + ...((localIosSimulator && input.relaunch && !input.execution.clearAppState) || + isDirectAppLaunch(input) ? { terminateRunningApp: true } : {}), }); diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 3866045f35..3d2861488f 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -393,7 +393,9 @@ test('open --relaunch --clear-app-state on iOS simulator keeps close-first order expect(response.ok).toBe(true); expect(calls).toEqual(['close:com.example.app', 'open:com.example.app']); - expect(openContext?.terminateRunningApp).toBeUndefined(); + // clearAppState is a direct-launch shape: the open itself also terminates + // any straggler process (belt-and-braces after the close above). + expect(openContext?.terminateRunningApp).toBe(true); }); test('open --relaunch includes timing and waits for iOS runner prewarm after opening app', async () => { diff --git a/test/integration/provider-scenarios/ios-lifecycle.test.ts b/test/integration/provider-scenarios/ios-lifecycle.test.ts index 2becd3f0c5..8cb08bc1db 100644 --- a/test/integration/provider-scenarios/ios-lifecycle.test.ts +++ b/test/integration/provider-scenarios/ios-lifecycle.test.ts @@ -281,6 +281,7 @@ test('Provider-backed integration iOS Settings flow uses scripted simctl and run 'simctl', 'launch', '--console-pty', + '--terminate-running-process', 'sim-1', 'com.apple.Preferences', ]); From 4ca05158a0868054e271e409f585bfe14f0e4e5b Mon Sep 17 00:00:00 2001 From: BK Date: Mon, 28 Sep 2026 15:53:31 +0700 Subject: [PATCH 3/3] =?UTF-8?q?feat(client):=20interactions.keyboardDismis?= =?UTF-8?q?s=20=E2=80=94=20engine-backed=20keyboard=20dismissal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exposes the daemon 'keyboard dismiss' command through the interactions surface so callers can hide the on-screen keyboard without coordinate taps (device-independent; routes to the apple runner's dismissal or the android dismiss-key path per platform). TDD: red test asserted the daemon request shape (command 'keyboard', positional 'dismiss', udid flag) before the two-line impl. --- src/__tests__/client.test.ts | 21 +++++++++++++++++++++ src/agent-device-client.ts | 2 ++ 2 files changed, 23 insertions(+) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index f1353ca722..bff85d3e48 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -707,6 +707,27 @@ test('apps.installFromSource forwards GitHub Actions artifact sources unchanged' }); }); +test('interactions.keyboardDismiss issues system keyboard dismiss for the device', async () => { + const setup = createTransport(async (req) => { + assert.equal(req.command, 'keyboard'); + assert.deepEqual(req.positionals, ['dismiss']); + assert.equal((req.flags as Record).udid, 'UDID-1'); + return { + ok: true, + data: { + kind: 'keyboardDismissed', + action: 'dismiss', + state: { visible: false }, + message: 'Keyboard hidden', + }, + }; + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.interactions.keyboardDismiss({ udid: 'UDID-1' }); + assert.equal(result.kind, 'keyboardDismissed'); +}); + test('interactions.rotateGesture rejects partial centers on the client side', async () => { const setup = createTransport(async () => { throw new Error('transport should not run for invalid input'); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 5835fc6c83..7ce0770de1 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -391,6 +391,8 @@ export function createAgentDeviceClient( type: async (options) => await executeCommand('type', options), fill: async (options) => await executeCommand('fill', options), scroll: async (options) => await executeCommand('scroll', options), + keyboardDismiss: async (options: { udid?: string }) => + await executeCommand('keyboard', { action: 'dismiss', udid: options.udid }), pinch: async (options) => await executeCommand('gesture', pinchGestureInput(options)), rotateGesture: async (options) => await executeCommand('gesture', rotateGestureInput(options)),