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: 2 additions & 1 deletion packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,8 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
names: ['--launch-console'],
type: 'string',
usageLabel: '--launch-console <path>',
usageDescription: 'open: capture the initial iOS simulator launch console window to a file',
usageDescription:
'open: capture the initial local iOS simulator launch console window to a file',
projectConfig: false,
recorded: false,
},
Expand Down
28 changes: 28 additions & 0 deletions packages/contracts/src/application-lifecycle-interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { OpenApplicationInput } from './application-lifecycle-runtime.ts';
import {
bindDirectApplicationLifecycle,
bindLocalApplicationLifecycleInteractor,
bindProviderApplicationLifecycleInteractor,
invokeApplicationOpen,
} from './application-lifecycle-interaction.ts';

Expand Down Expand Up @@ -165,3 +166,30 @@ test.each([
}),
).rejects.toThrow(message);
});

test('provider interactor resolution refuses launch-console before a relaunch close', async () => {
const calls: string[] = [];
const binding = bindProviderApplicationLifecycleInteractor({
device: IOS_SIMULATOR,
signal: new AbortController().signal,
resolveInteractor: () => {
calls.push('resolve');
return interactorWithOpen();
},
});
const lifecycle = bindDirectApplicationLifecycle({
binding,
owner: 'provider',
openTargetIdentity: 'bundle-id',
closeBeforeRelaunch: true,
});
await expect(
lifecycle.openApplication(
openInput({ relaunch: true, execution: { launchConsole: '/tmp/app.log' } }),
),
).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'launch-console-owner-unadmitted', deviceId: IOS_SIMULATOR.id },
});
expect(calls).toEqual([]);
});
11 changes: 11 additions & 0 deletions packages/contracts/src/application-lifecycle-interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ function bindApplicationLifecycleInteractor(
resolveInteractor: async (execution, appBundleId) => {
const runner = applicationLifecycleRunnerContext(execution, appBundleId, signal);
if (ownership === 'local') return await params.resolveLocalInteractor(device, runner);
if (execution.launchConsole) {
throw new AppError(
'UNSUPPORTED_OPERATION',
'--launch-console is supported only for a local iOS simulator app launch',
{
reason: 'launch-console-owner-unadmitted',
deviceId: device.id,
hint: 'Remove --launch-console, or use a local iOS simulator for launch output.',
},
);
}
const interactor = ownership.resolveInteractor(runner);
if (interactor) return interactor;
throw new AppError(
Expand Down
4 changes: 2 additions & 2 deletions src/commands/management/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const openCommandMetadata = defineFieldCommandMetadata(
),
activity: stringField('Android activity name.'),
launchConsole: stringField(
'File path on the daemon host for the stdout and stderr of the initial app launch. iOS simulator direct app launches only; not for URL opens.',
'File path on the daemon host for the stdout and stderr of the initial app launch. Local iOS simulator direct app launches only; not for URL opens.',
),
launchArgs: stringArrayField(
'Launch arguments forwarded verbatim to the platform launch command.',
Expand Down Expand Up @@ -199,7 +199,7 @@ export const openCommandFacet = defineCommandFacet({
text: {
summary: 'Open an app, deep link or URL, save replays',
cliDetail:
'Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console <path> on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+<slug> scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in <App>?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app\'s own alert (such as a permission prompt) rather than a translated launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait <ms> to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.',
'Use --platform to bind URL/deep-link opens to the target platform. For local iOS simulator initial stdout/stderr, put --launch-console <path> on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+<slug> scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in <App>?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app\'s own alert (such as a permission prompt) rather than a translated launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait <ms> to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.',
mcpDetail:
"metroHost, metroPort, and bundleUrl choose the app's Metro server and save its address for later reloads. launchUrl selects the app's launch URL and does not select the reload server. A fresh open without Metro hints clears an older same-name binding; close also clears it.",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ import {
} from '@agent-device/contracts/application-lifecycle-runtime';
import {
bindProviderApplicationLifecycleInteractor,
invokeApplicationClose,
invokeApplicationOpen,
bindDirectApplicationLifecycle,
} from '@agent-device/contracts/application-lifecycle-interaction';
import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime';
import { bindProviderSnapshotInteractor } from '@agent-device/contracts/snapshot-runtime';
Expand Down Expand Up @@ -201,39 +200,20 @@ function providerScenarioApplicationLifecycle(
signal,
resolveInteractor: (runner) => runtime.getInteractor(device, runner),
});
const direct = bindDirectApplicationLifecycle({
binding,
owner: 'scenario-provider',
openTargetIdentity: 'bundle-id',
closeBeforeRelaunch: true,
});
return Object.freeze({
...direct,
resolveOpenTarget: async (input) => ({
...(input.target?.includes('.') ? { appBundleId: input.target } : {}),
...(input.target === undefined ? {} : { appName: input.target }),
}),
prepareApplicationOpen: async () => undefined,
openApplication: async (input) => {
if (input.relaunch && input.target) {
await invokeApplicationClose({
device,
interactor: await binding.resolveInteractor(input.execution, input.appBundleId),
positionals: [input.appBundleId ?? input.target],
});
}
await invokeApplicationOpen({
device,
interactor: await binding.resolveInteractor(input.execution, input.appBundleId),
positionals: input.positionals,
appBundleId: input.appBundleId,
execution: input.execution,
});
return { appBundleId: input.appBundleId, timing: {} };
},
applyRuntimeHints: async () => undefined,
clearRuntimeHints: async () => undefined,
closeApplication: async (input) => {
await invokeApplicationClose({
device,
interactor: await binding.resolveInteractor(input.execution, input.appBundleId),
positionals: input.positionals,
});
},
finalizeApplicationClose: async () => ({}),
prepareAppleRunner: async () => {
throw new AppError('UNSUPPORTED_OPERATION', 'Fake provider does not prepare Apple runners.');
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,38 @@ test('provider-owned iOS simulators relaunch without local CoreSimulator refresh
);
});

test('provider-owned relaunch refuses launch-console without closing the running app', async () => {
await withProviderScenarioResource(
async () => await createFakeProviderWorld('ios'),
async ({ daemon, runtime }) => {
const lease = await allocateIosFakeProviderLease(daemon);
const flags = iosLeaseFlags(lease.leaseId);
const options = { meta: iosLeaseMeta(lease.leaseId) };
assertRpcOk(await daemon.callCommand('open', ['com.example.demo'], flags, options));
const before = runtime.calls.length;
const error = assertRpcError(
await daemon.callCommand(
'open',
['com.example.demo'],
{ ...flags, relaunch: true, launchConsole: '/tmp/app.log' },
options,
),
500,
'UNSUPPORTED_OPERATION',
/local iOS simulator/,
);
assert.equal(
(error.details as Record<string, unknown>).reason,
'launch-console-owner-unadmitted',
);
assert.deepEqual(
runtime.calls.slice(before).filter((call) => call.type === 'open' || call.type === 'close'),
[],
);
},
);
});

test('provider lease allocation is refused when no registered runtime claims the provider', async () => {
const daemon = await createProviderScenarioHarness({
providerRuntimeIds: [FAKE_PROVIDER],
Expand Down
4 changes: 2 additions & 2 deletions website/docs/docs/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ This example opens an app, takes a snapshot, interacts, handles typed errors, an

`client.devices.capabilities()` returns `{ device, availableCommands }`, using the same capability matrix as the CLI. Use it to check which command names the selected target supports.

For direct iOS simulator app launches, `client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' })` captures launch-time
stdout/stderr. The option mirrors `open --launch-console` and is not valid for URL opens or non-simulator targets.
For direct local iOS simulator app launches, `client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' })` captures launch-time
stdout/stderr. The option mirrors `open --launch-console` and is not valid for URL opens, provider-owned simulators, or non-simulator targets.

`client.sessions.stateDir()` mirrors `session state-dir` and returns the resolved daemon state directory without starting
or contacting the daemon. Pass `{ stateDir }` to resolve an explicit override the same way the CLI resolves `--state-dir`.
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Open apps and links:
- `open [app|url] [url]` boots the selected device when needed and opens the target in a session. `open <url>` opens deep links on Android and iOS, and `open <app> <url>` opens a deep link in that app on iOS. See [Desktop targets](#desktop-targets) for macOS activation behavior.
- On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom-scheme URLs need an active app in the session.
- `open <app> --wait <ms>` waits up to that budget for a device another session holds, then fails with `DEVICE_IN_USE`. The wait extends the command's timeout, so you don't need a longer `--timeout`. A claim held by another workspace's daemon is not waited for; `open` returns its recovery command at once. Device-mutating commands like `install` and `reinstall` never wait either: a claim held by another session fails them at once, and when the caller sends a workspace, another workspace's claim under the same session name fails too. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device).
- `open <app> --launch-console <path>` captures launch-time stdout/stderr for iOS simulator app launches. It is not valid for URL opens or other targets.
- `open <app> --launch-console <path>` captures launch-time stdout/stderr for local iOS simulator app launches. It is not valid for URL opens or other targets.
- `open --platform macos --surface app|frontmost-app|desktop|menubar` selects the macOS session surface; `app` is the default when you name an app. See [Desktop targets](#desktop-targets). `--save-script` records the surface so `replay` reopens it; with no surface given, replay opens an app session.
- Android React Native: `open --relaunch` needs an installed package name. Install or reinstall the APK first; `open <apk|aab> --relaunch` is rejected. For Metro-backed JS changes, run `metro reload` before reaching for `open <app> --relaunch`.
- Android (adb): an intent target such as `open settings` records the package that opened as `appBundleId`. `open settings --relaunch` stops and restarts that app. If the intent matches more than one activity, `--relaunch` fails.
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/debugging-profiling.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ On iOS simulators, `logs` scope by bundle id and the resolved app executable. Fo
agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/app.console.log
```

`--launch-console` is only for direct iOS simulator app launches, not URL opens.
`--launch-console` is only for direct local iOS simulator app launches, not URL opens.

## Crash symbolication

Expand Down
Loading