Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions src/core/dispatch-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export type DispatchContext = ScreenshotDispatchFlags & {
activity?: string;
launchConsole?: string;
launchArgs?: string[];
// iOS simulator only: relaunch via a single `simctl launch
// --terminate-running-process` instead of a separate terminate + launch.
terminateRunningApp?: boolean;
clearAppState?: boolean;
verbose?: boolean;
logPath?: string;
Expand Down
1 change: 1 addition & 0 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ async function handleOpenCommand(
appBundleId: context?.appBundleId,
launchConsole,
launchArgs,
terminateRunningApp: context?.terminateRunningApp,
});
return { app, ...(launchConsole ? { launchConsole } : {}), ...successText(`Opened: ${app}`) };
}
Expand Down
1 change: 1 addition & 0 deletions src/core/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type Interactor = {
appBundleId?: string;
launchConsole?: string;
launchArgs?: string[];
terminateRunningApp?: boolean;
url?: string;
},
): Promise<void>;
Expand Down
114 changes: 108 additions & 6 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3238,7 +3238,7 @@ test('open --relaunch on iOS stops runner before close/open', async () => {
expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']);
});

test('open --relaunch on iOS simulator keeps runner hot across close/open', async () => {
test('open --relaunch on iOS simulator collapses into one terminate-running open dispatch', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-simulator-session';
sessionStore.set(sessionName, {
Expand All @@ -3263,8 +3263,10 @@ test('open --relaunch on iOS simulator keeps runner hot across close/open', asyn
mockStopIosRunner.mockImplementation(async () => {
calls.push('stop-runner');
});
mockDispatch.mockImplementation(async (_device, command, positionals) => {
let openContext: Record<string, unknown> | undefined;
mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => {
calls.push(`${command}:${positionals.join(' ')}`);
if (command === 'open') openContext = context as Record<string, unknown>;
return {};
});

Expand All @@ -3282,9 +3284,110 @@ test('open --relaunch on iOS simulator keeps runner hot across close/open', asyn
invoke: noopInvoke,
});

expect(response).toBeTruthy();
expect(response?.ok).toBe(true);
expect(calls).toEqual(['open:com.example.app']);
expect(openContext?.terminateRunningApp).toBe(true);
});

test('open <app> <url> --relaunch on iOS simulator keeps close-first ordering', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-simulator-url-relaunch-session';
sessionStore.set(sessionName, {
...makeSession(sessionName, {
platform: 'apple',
id: 'sim-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
booted: true,
}),
appName: 'com.example.app',
});

const calls: string[] = [];
mockResolveTargetDevice.mockResolvedValue({
platform: 'apple',
id: 'sim-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
booted: true,
});
let openContext: Record<string, unknown> | undefined;
mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => {
calls.push(`${command}:${positionals.join(' ')}`);
if (command === 'open') openContext = context as Record<string, unknown>;
return {};
});

const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'open',
positionals: ['com.example.app', 'https://example.com/deal'],
flags: { relaunch: true },
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response).toBeTruthy();
expect(response?.ok).toBe(true);
// The URL dispatch path cannot carry the terminate, so the relaunch keeps
// the explicit close-then-open sequence.
expect(calls).toEqual(['close:com.example.app', 'open:com.example.app https://example.com/deal']);
expect(openContext?.terminateRunningApp).toBeUndefined();
});

test('open --relaunch --clear-app-state on iOS simulator keeps close-first ordering', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-simulator-clear-state-session';
sessionStore.set(sessionName, {
...makeSession(sessionName, {
platform: 'apple',
id: 'sim-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
booted: true,
}),
appName: 'com.example.app',
});

const calls: string[] = [];
mockResolveTargetDevice.mockResolvedValue({
platform: 'apple',
id: 'sim-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
booted: true,
});
let openContext: Record<string, unknown> | undefined;
mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => {
calls.push(`${command}:${positionals.join(' ')}`);
if (command === 'open') openContext = context as Record<string, unknown>;
return {};
});

const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'open',
positionals: [],
flags: { relaunch: true, clearAppState: true },
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response).toBeTruthy();
expect(response?.ok).toBe(true);
expect(calls).toEqual(['close:com.example.app', 'open:com.example.app']);
expect(openContext?.terminateRunningApp).toBeUndefined();
});

test('open --relaunch includes timing and waits for iOS runner prewarm after opening app', async () => {
Expand Down Expand Up @@ -3395,7 +3498,7 @@ test('open --relaunch on iOS without existing session closes then opens target a
expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']);
});

test('open --relaunch on iOS simulator reaches settle path for close and open', async () => {
test('open --relaunch on iOS simulator settles once after the collapsed open', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-sim-session';
sessionStore.set(sessionName, {
Expand Down Expand Up @@ -3437,9 +3540,8 @@ test('open --relaunch on iOS simulator reaches settle path for close and open',

expect(response).toBeTruthy();
expect(response?.ok).toBe(true);
expect(settleCalls.length).toBe(2);
expect(settleCalls[0]).toEqual({ deviceId: 'sim-1', delayMs: 300 });
expect(settleCalls[1]).toEqual({ deviceId: 'sim-1', delayMs: 300 });
// Collapsed simulator relaunch skips the post-close settle: one settle after open.
expect(settleCalls).toEqual([{ deviceId: 'sim-1', delayMs: 300 }]);
});

test('close on macOS session stops runner and dismisses automation alert before delete', async () => {
Expand Down
16 changes: 15 additions & 1 deletion src/daemon/handlers/session-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,20 @@ async function completeOpenCommand(params: {
schedulePrewarm();
}

if (shouldRelaunch && openTarget) {
// iOS simulators relaunch with one `simctl launch --terminate-running-process`
// instead of terminate + settle + launch (~1s per relaunch). Runtime hints
// written below are user-defaults reads at that launch, so ordering holds.
// Only the single app-launch form collapses: `open <app> <url>` dispatches
// through the URL path, where a deep-link open never launches the app and
// so cannot carry the terminate; those keep the close-first ordering, as
// does --clear-app-state, which must never mutate a running app's container.
const collapseSimulatorRelaunch =
shouldRelaunch &&
Boolean(openTarget) &&
openPositionals.length === 1 &&
isIosSimulator(device) &&
req.flags?.clearAppState !== true;
if (shouldRelaunch && openTarget && !collapseSimulatorRelaunch) {
const closeTarget = sessionAppBundleId ?? openTarget;
const closeStartedAtMs = Date.now();
await relaunchCloseApp({
Expand Down Expand Up @@ -255,6 +268,7 @@ async function completeOpenCommand(params: {
const openDispatchSession = provisionalSession.session ?? existingSession;
await dispatchCommand(device, 'open', openPositionals, req.flags?.out, {
...contextFromFlags(logPath, req.flags, sessionAppBundleId),
...(collapseSimulatorRelaunch ? { terminateRunningApp: true } : {}),
});
timing.openDispatchDurationMs = Math.max(0, Date.now() - openStartedAtMs);
const launchUrlStartedAtMs = Date.now();
Expand Down
14 changes: 11 additions & 3 deletions src/platforms/apple/core/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,13 @@ function parseUrlScheme(url: string): string | undefined {
export async function openIosApp(
device: DeviceInfo,
app: string,
options?: { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; url?: string },
options?: {
appBundleId?: string;
launchConsole?: string;
launchArgs?: string[];
terminateRunningApp?: boolean;
url?: string;
},
): Promise<void> {
const launchConsole = options?.launchConsole?.trim();
const launchArgs = options?.launchArgs;
Expand Down Expand Up @@ -258,6 +264,7 @@ export async function openIosApp(
await launchIosSimulatorApp(device, bundleId, {
...(launchConsole ? { launchConsole } : {}),
...(launchArgs ? { launchArgs } : {}),
...(options?.terminateRunningApp ? { terminateRunningApp: true } : {}),
});
return;
}
Expand Down Expand Up @@ -1112,7 +1119,7 @@ function isIosBiometricCapabilityMissing(stdout: string, stderr: string): boolea
async function launchIosSimulatorApp(
device: DeviceInfo,
bundleId: string,
options?: { launchConsole?: string; launchArgs?: string[] },
options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean },
): Promise<void> {
await ensureBootedSimulator(device);

Expand Down Expand Up @@ -1175,10 +1182,11 @@ async function launchIosSimulatorApp(
function buildIosSimulatorLaunchArgs(
deviceId: string,
bundleId: string,
options?: { launchConsole?: string; launchArgs?: string[] },
options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean },
): string[] {
const args = ['launch'];
if (options?.launchConsole) args.push('--console-pty');
if (options?.terminateRunningApp) args.push('--terminate-running-process');
args.push(deviceId, bundleId);
if (options?.launchArgs && options.launchArgs.length > 0) {
args.push(...options.launchArgs);
Expand Down
1 change: 1 addition & 0 deletions src/platforms/apple/interactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function createAppleInteractor(
appBundleId: options?.appBundleId,
launchConsole: options?.launchConsole,
launchArgs: options?.launchArgs,
terminateRunningApp: options?.terminateRunningApp,
url: options?.url,
}),
openDevice: () => openIosDevice(device),
Expand Down
Loading