Skip to content
Open
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
11 changes: 11 additions & 0 deletions packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,17 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
projectConfig: true,
recorded: false,
},
{
key: 'launchEnvironmentEntries',
names: ['--launch-env'],
type: 'string',
multiple: true,
usageLabel: '--launch-env <KEY=VALUE>',
usageDescription:
'open: repeatable iOS Simulator child-process environment entry; values are treated as sensitive',
projectConfig: false,
recorded: false,
},
{
key: 'header',
names: ['--header'],
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@
"types": "./src/clipboard-runtime.ts",
"default": "./src/clipboard-runtime.ts"
},
"./launch-environment": {
"types": "./src/launch-environment.ts",
"default": "./src/launch-environment.ts"
},
"./command": {
"types": "./src/facades/command.ts",
"default": "./src/facades/command.ts"
Expand Down
109 changes: 100 additions & 9 deletions packages/contracts/src/application-lifecycle-interaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { Interactor } from './interactor-types.ts';
import type { OpenApplicationInput } from './application-lifecycle-runtime.ts';
import {
bindDirectApplicationLifecycle,
bindLocalApplicationLifecycleInteractor,
bindProviderApplicationLifecycleInteractor,
invokeApplicationOpen,
} from './application-lifecycle-interaction.ts';

Expand All @@ -17,6 +18,20 @@ const IOS_SIMULATOR: DeviceInfo = {
booted: true,
};

const IPADOS_SIMULATOR: DeviceInfo = {
...IOS_SIMULATOR,
appleOs: 'ipados',
id: 'ipados-simulator',
name: 'iPad',
};

const TVOS_SIMULATOR: DeviceInfo = {
...IOS_SIMULATOR,
appleOs: 'tvos',
id: 'tvos-simulator',
name: 'Apple TV',
};

const LINUX_DEVICE: DeviceInfo = {
platform: 'linux',
id: 'linux-local',
Expand All @@ -25,12 +40,10 @@ const LINUX_DEVICE: DeviceInfo = {
booted: true,
};

const WEB_DEVICE: DeviceInfo = {
platform: 'web',
id: 'web-local',
name: 'Browser',
const IOS_PHYSICAL_DEVICE: DeviceInfo = {
...IOS_SIMULATOR,
id: 'ios-device',
kind: 'device',
booted: true,
};

function interactorWithOpen(open: Interactor['open'] = async () => undefined): Interactor {
Expand Down Expand Up @@ -65,30 +78,36 @@ test('direct lifecycle owners preserve the daemon runtime launch URL follow-up',
calls.push({ app, options });
});
const binding = bindLocalApplicationLifecycleInteractor({
device: WEB_DEVICE,
device: IOS_SIMULATOR,
signal: new AbortController().signal,
resolveInteractor: async () => interactor,
});
const lifecycle = bindDirectApplicationLifecycle({
binding,
owner: 'Linux',
owner: 'iOS Simulator',
openTargetIdentity: 'app-name',
});

await lifecycle.openApplication(
openInput({
runtimeLaunchUrl: 'example://after-open',
execution: { clearAppState: true, launchArgs: ['--first-launch'] },
execution: {
clearAppState: true,
launchArgs: ['--first-launch'],
launchEnvironment: { MODE: 'test' },
},
}),
);

expect(calls.map(({ app }) => app)).toEqual(['com.example.app', 'example://after-open']);
expect(calls[0]?.options).toMatchObject({
appBundleId: 'com.example.app',
launchArgs: ['--first-launch'],
launchEnvironment: { MODE: 'test' },
});
expect(calls[1]?.options).toMatchObject({ appBundleId: 'com.example.app' });
expect(calls[1]?.options).toHaveProperty('launchArgs', undefined);
expect(calls[1]?.options).toHaveProperty('launchEnvironment', undefined);
});

test.each([
Expand All @@ -113,6 +132,34 @@ test.each([
execution: { launchArgs: ['--flag'] },
message: /launch-args requires an app target/,
},
{
name: 'launch environment without an app',
device: IOS_SIMULATOR,
positionals: [],
execution: { launchEnvironment: { MODE: 'test' } },
message: /launch-env requires an app target/,
},
{
name: 'launch environment on a physical iOS device',
device: IOS_PHYSICAL_DEVICE,
positionals: ['com.example.app'],
execution: { launchEnvironment: { MODE: 'test' } },
message: /only for iOS Simulator/,
},
{
name: 'launch environment on a non-iOS Apple simulator',
device: TVOS_SIMULATOR,
positionals: ['com.example.app'],
execution: { launchEnvironment: { MODE: 'test' } },
message: /only for iOS Simulator/,
},
{
name: 'launch environment on Linux',
device: LINUX_DEVICE,
positionals: ['org.example.App'],
execution: { launchEnvironment: { MODE: 'test' } },
message: /only for iOS Simulator/,
},
{
name: 'launch console outside an iOS simulator',
device: LINUX_DEVICE,
Expand Down Expand Up @@ -165,3 +212,47 @@ test.each([
}),
).rejects.toThrow(message);
});

test('iPadOS Simulator accepts launch environment for app launches', async () => {
const calls: Array<{ app: string; options: unknown }> = [];
const lifecycle = bindDirectApplicationLifecycle({
binding: bindLocalApplicationLifecycleInteractor({
device: IPADOS_SIMULATOR,
signal: new AbortController().signal,
resolveInteractor: async () =>
interactorWithOpen(async (app, options) => {
calls.push({ app, options });
}),
}),
owner: 'iPadOS Simulator',
openTargetIdentity: 'bundle-id',
});

await lifecycle.openApplication(
openInput({ execution: { launchEnvironment: { MODE: 'test' } } }),
);

expect(calls).toHaveLength(1);
expect(calls[0]?.options).toMatchObject({ launchEnvironment: { MODE: 'test' } });
});

test('provider-owned iOS Simulator lifecycle rejects launch environment before dispatch', async () => {
const open = vi.fn(async () => undefined);
const lifecycle = bindDirectApplicationLifecycle({
binding: bindProviderApplicationLifecycleInteractor({
device: IOS_SIMULATOR,
signal: new AbortController().signal,
resolveInteractor: () => interactorWithOpen(open),
}),
owner: 'Limrun',
openTargetIdentity: 'bundle-id',
});

await expect(
lifecycle.openApplication(openInput({ execution: { launchEnvironment: { MODE: 'test' } } })),
).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'unsupported-provider-mode' },
});
expect(open).not.toHaveBeenCalled();
});
32 changes: 30 additions & 2 deletions packages/contracts/src/application-lifecycle-interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE,
} from './launch-console.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { isIosFamily } from '@agent-device/kernel/device';
import { isHandheldAppleSimulator } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import type { Interactor, RunnerContext } from './interactor-types.ts';
import type {
Expand All @@ -25,6 +25,7 @@ import type {
*/
export type ApplicationLifecycleInteractorBinding = Readonly<{
device: DeviceInfo;
providerOwned: boolean;
signal: AbortSignal;
resolveInteractor(
execution: ApplicationLifecycleExecution,
Expand Down Expand Up @@ -83,6 +84,7 @@ function bindApplicationLifecycleInteractor(
const { device, signal, ownership } = params;
return Object.freeze({
device,
providerOwned: ownership !== 'local',
signal,
resolveInteractor: async (execution, appBundleId) => {
const runner = applicationLifecycleRunnerContext(execution, appBundleId, signal);
Expand Down Expand Up @@ -181,19 +183,35 @@ async function invokeDeviceOpen(params: DirectOpenParameters): Promise<void> {
if (params.execution.launchArgs && params.execution.launchArgs.length > 0) {
throw new AppError('INVALID_ARGS', '--launch-args requires an app target');
}
if (params.execution.launchEnvironment !== undefined) {
throw new AppError('INVALID_ARGS', '--launch-env requires an app target');
}
await params.interactor.openDevice();
}

function assertOpenDeviceSupport(
device: DeviceInfo,
execution: ApplicationLifecycleExecution,
): void {
if (execution.launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) {
if (execution.launchConsole && !isHandheldAppleSimulator(device)) {
throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE);
}
if (device.platform === 'linux' && execution.launchArgs && execution.launchArgs.length > 0) {
throw new AppError('UNSUPPORTED_OPERATION', '--launch-args is not supported on Linux.');
}
assertLaunchEnvironmentSupport(device, execution.launchEnvironment);
}

function assertLaunchEnvironmentSupport(
device: DeviceInfo,
launchEnvironment: ApplicationLifecycleExecution['launchEnvironment'],
): void {
if (launchEnvironment !== undefined && !isHandheldAppleSimulator(device)) {
throw new AppError(
'UNSUPPORTED_OPERATION',
'--launch-env is supported only for iOS Simulator app launches.',
);
}
}

async function invokeApplicationUrlOpen(
Expand All @@ -217,6 +235,7 @@ async function invokeApplicationUrlOpen(
activity: params.execution.activity,
appBundleId: params.appBundleId,
launchArgs: params.execution.launchArgs ? [...params.execution.launchArgs] : undefined,
launchEnvironment: params.execution.launchEnvironment,
terminateRunningApp: params.terminateRunningApp,
url,
});
Expand Down Expand Up @@ -244,6 +263,7 @@ async function invokeApplicationTargetOpen(
appBundleId: params.appBundleId,
launchConsole: execution.launchConsole,
launchArgs: execution.launchArgs ? [...execution.launchArgs] : undefined,
launchEnvironment: execution.launchEnvironment,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
terminateRunningApp: params.terminateRunningApp,
});
}
Expand Down Expand Up @@ -305,6 +325,13 @@ async function openDirectApplication(
input: OpenApplicationInput,
): Promise<OpenApplicationOutcome> {
const { binding } = params;
if (binding.providerOwned && input.execution.launchEnvironment !== undefined) {
throw new AppError(
'UNSUPPORTED_OPERATION',
`Launch environment is not supported by the ${params.owner} application provider.`,
{ reason: 'unsupported-provider-mode' },
);
}
const interactor = await binding.resolveInteractor(input.execution, input.appBundleId);
if (params.closeBeforeRelaunch && input.relaunch && input.target !== undefined) {
await invokeApplicationClose({
Expand Down Expand Up @@ -332,6 +359,7 @@ async function openDirectApplication(
clearAppState: undefined,
launchConsole: undefined,
launchArgs: undefined,
launchEnvironment: undefined,
},
});
}
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts';
import type { SessionSurface } from './session-surface.ts';
import type { ProviderPortReverseOptions } from './provider-device-runtime.ts';
import type { TargetShutdownResult } from './target-shutdown-contract.ts';
import type { LaunchEnvironment } from './launch-environment.ts';

/**
* A deliberately neutral runtime-hint payload. Daemon policy owns parsing and
Expand Down Expand Up @@ -40,6 +41,7 @@ export type ApplicationLifecycleExecution = Readonly<{
activity?: string;
launchConsole?: string;
launchArgs?: readonly string[];
launchEnvironment?: LaunchEnvironment;
clearAppState?: boolean;
iosXctestrunFile?: string;
iosXctestDerivedDataPath?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export type CliFlags = CloudProviderProfileFields &
activity?: string;
launchConsole?: string;
launchArgs?: string[];
launchEnvironmentEntries?: string[];
header?: string[];
githubActionsArtifact?: string;
installSource?: DaemonInstallSource;
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { AppsFilter } from './app-inventory.ts';
import type { JsonObject } from './json.ts';
import type { LaunchEnvironment } from './launch-environment.ts';
import type { SessionSurface } from './session-surface.ts';
import type { TargetShutdownResult } from './target-shutdown-contract.ts';
import type { DaemonInstallSource, SessionRuntimeHints } from '@agent-device/kernel/contracts';
Expand Down Expand Up @@ -65,6 +66,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
activity?: string;
launchConsole?: string;
launchArgs?: string[];
launchEnvironment?: LaunchEnvironment;
relaunch?: boolean;
/** Startup budget in milliseconds: bounds the Simulator boot wait on a cold device. */
timeoutMs?: number;
Expand Down
3 changes: 2 additions & 1 deletion packages/contracts/src/command-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ export type MaestroRuntimeFlags = {
screenshotCaptureBackend?: 'runner';
};

export type CommandFlags = Omit<CliFlags, DaemonExcludedCliFlag> & {
export type CommandFlags = Omit<CliFlags, DaemonExcludedCliFlag | 'launchEnvironmentEntries'> & {
batchSteps?: DaemonBatchStep[];
clearAppState?: boolean;
interactionOutcome?: {
retryOnNoChange?: boolean;
};
launchArgs?: string[];
launchEnvironment?: Readonly<Record<string, string>>;
kind?: string;
maestro?: MaestroRuntimeFlags;
postGestureStabilization?: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export type Interactor = {
appBundleId?: string;
launchConsole?: string;
launchArgs?: string[];
launchEnvironment?: Readonly<Record<string, string>>;
terminateRunningApp?: boolean;
url?: string;
},
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/launch-environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type LaunchEnvironment = Readonly<Record<string, string>>;
2 changes: 2 additions & 0 deletions packages/contracts/src/request-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { SnapshotCommandOptionFields } from '@agent-device/kernel/snapshot'
import type { DaemonBatchStep } from './batch-step.ts';
import type { ReplayRequestFields } from './replay-request-fields.ts';
import type { AgentDeviceClientConfig, AgentDeviceSelectionOptions } from './client-connection.ts';
import type { LaunchEnvironment } from './launch-environment.ts';

export type CommandExecutionOptions = Partial<ScreenshotRequestFlags> &
ReplayRequestFields &
Expand Down Expand Up @@ -68,6 +69,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
activity?: string;
launchConsole?: string;
launchArgs?: string[];
launchEnvironment?: LaunchEnvironment;
relaunch?: boolean;
shutdown?: boolean;
saveScript?: boolean | string;
Expand Down
Loading