mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
feat(daemon): [RFC] prototype foreground-attach convenience (#1670)
Prototype `open --foreground`: on a fresh session with no app argument, auto-resolves the target from the sole booted iOS simulator's sole foreground app (reusing the exact same ambiguity-detection probe that enriches the SESSION_NOT_FOUND hint), then attaches the initial interactive snapshot to the response by composing the existing snapshot-runtime dispatch. Collapses the documented 3-call snapshot-fails -> read-hint -> open -> snapshot-succeeds dance into a single call for the unambiguous case, while failing closed (AMBIGUOUS_MATCH) with no guessing otherwise. First-pass RFC, not reviewed — see PR body for the design tradeoff writeup, live before/after evidence, and scoped-out follow-ups.
This commit is contained in:
committed by
GitHub
parent
4f510ebd18
commit
cc943400a9
@@ -116,6 +116,13 @@ export type CliFlags = CloudProviderProfileFields &
|
||||
saveScript?: boolean | string;
|
||||
shutdown?: boolean;
|
||||
relaunch?: boolean;
|
||||
/**
|
||||
* RFC prototype (open --foreground, branch claude/observe-foreground): resolve the
|
||||
* app target from the sole booted iOS simulator's sole running app instead of
|
||||
* requiring an explicit app argument. Fails closed (AMBIGUOUS_MATCH) unless the
|
||||
* environment is unambiguous.
|
||||
*/
|
||||
foreground?: boolean;
|
||||
surface?: SessionSurface;
|
||||
headless?: boolean;
|
||||
restart?: boolean;
|
||||
|
||||
@@ -45,6 +45,13 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
|
||||
launchConsole?: string;
|
||||
launchArgs?: string[];
|
||||
relaunch?: boolean;
|
||||
/**
|
||||
* RFC prototype (branch claude/observe-foreground): resolve the app target from the
|
||||
* sole booted iOS simulator's sole running app instead of an explicit app
|
||||
* argument, and return an initial interactive snapshot alongside the open result.
|
||||
* Fails closed (AMBIGUOUS_MATCH) unless the environment is unambiguous.
|
||||
*/
|
||||
foreground?: boolean;
|
||||
saveScript?: boolean | string;
|
||||
/** #1258: overwrite an existing --save-script target instead of refusing. Alias: --overwrite. */
|
||||
force?: boolean;
|
||||
@@ -67,6 +74,16 @@ export type AppOpenResult = {
|
||||
startup?: StartupPerfSample;
|
||||
runtime?: SessionRuntimeHints;
|
||||
device?: AgentDeviceSessionDevice;
|
||||
/**
|
||||
* RFC prototype (open --foreground, branch claude/observe-foreground): the initial
|
||||
* interactive snapshot captured immediately after open, composed from the same
|
||||
* snapshot-runtime dispatch `agent-device snapshot -i` uses. Present only when
|
||||
* `--foreground` triggered the auto-resolve path. Left loosely typed for this
|
||||
* prototype rather than reusing `CaptureSnapshotResult` end to end (see PR
|
||||
* follow-ups) since the daemon-side composition does not attach client-only
|
||||
* fields like `identifiers`.
|
||||
*/
|
||||
snapshot?: Record<string, unknown>;
|
||||
identifiers: AgentDeviceIdentifiers;
|
||||
};
|
||||
|
||||
|
||||
@@ -340,6 +340,11 @@ function summarizeProviderScenarioFlagExclusions() {
|
||||
owner: 'handler and Android platform unit tests',
|
||||
keys: ['headless', 'testIme'],
|
||||
},
|
||||
{
|
||||
name: 'open foreground auto-resolution (RFC prototype)',
|
||||
owner: 'daemon session-open-foreground handler unit tests',
|
||||
keys: ['foreground'],
|
||||
},
|
||||
{
|
||||
name: 'Apple simulator screenshot rendering options',
|
||||
owner: 'iOS platform and screenshot-diff runtime tests',
|
||||
|
||||
@@ -279,6 +279,11 @@ export function createAgentDeviceClient(
|
||||
startup: normalizeStartupSample(data.startup),
|
||||
runtime: normalizeRuntimeHints(data.runtime),
|
||||
device,
|
||||
// RFC prototype (open --foreground): only present when the daemon's
|
||||
// foreground-attach composition captured an initial snapshot.
|
||||
...(data.snapshot && typeof data.snapshot === 'object'
|
||||
? { snapshot: data.snapshot as Record<string, unknown> }
|
||||
: {}),
|
||||
identifiers: {
|
||||
session,
|
||||
deviceId: device?.id,
|
||||
|
||||
@@ -273,6 +273,14 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
|
||||
usageLabel: '--relaunch',
|
||||
usageDescription: 'open: terminate app process before launching it',
|
||||
},
|
||||
{
|
||||
key: 'foreground',
|
||||
names: ['--foreground'],
|
||||
type: 'boolean',
|
||||
usageLabel: '--foreground',
|
||||
usageDescription:
|
||||
"[RFC] open: resolve app + return an initial snapshot from the sole booted iOS simulator's sole running app",
|
||||
},
|
||||
{
|
||||
key: 'restart',
|
||||
names: ['--restart'],
|
||||
|
||||
@@ -50,6 +50,9 @@ const openCommandMetadata = defineFieldCommandMetadata(
|
||||
'Launch arguments forwarded verbatim to the platform launch command.',
|
||||
),
|
||||
relaunch: booleanField('Force relaunch.'),
|
||||
foreground: booleanField(
|
||||
"[RFC prototype] On a fresh session with no app argument, resolve the target from the sole booted iOS simulator's sole running app and include an initial interactive snapshot in the response. Fails with AMBIGUOUS_MATCH when the environment is not unambiguous (0 or 2+ booted simulators, or no confidently-detectable running app) — never guesses.",
|
||||
),
|
||||
saveScript: jsonSchemaField<boolean | string>({
|
||||
oneOf: [booleanSchema(), stringSchema()],
|
||||
}),
|
||||
@@ -130,6 +133,7 @@ const openCliSchema = {
|
||||
'force',
|
||||
'noRecord',
|
||||
'relaunch',
|
||||
'foreground',
|
||||
'surface',
|
||||
...METRO_RELOAD_FLAGS,
|
||||
'launchUrl',
|
||||
@@ -155,6 +159,7 @@ const openCliReader: CliReader = (positionals, flags) => ({
|
||||
launchConsole: flags.launchConsole,
|
||||
launchArgs: flags.launchArgs,
|
||||
relaunch: flags.relaunch,
|
||||
foreground: flags.foreground,
|
||||
saveScript: flags.saveScript,
|
||||
force: flags.force,
|
||||
deviceHub: flags.deviceHub,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const resolveSoleForegroundIosApp = vi.hoisted(() => vi.fn());
|
||||
const dispatchSnapshotViaRuntime = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../ios-app-session-hint.ts', () => ({ resolveSoleForegroundIosApp }));
|
||||
vi.mock('../../snapshot-runtime.ts', () => ({ dispatchSnapshotViaRuntime }));
|
||||
|
||||
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
|
||||
import type { DaemonRequest, DaemonResponse } from '../../types.ts';
|
||||
import {
|
||||
composeOpenWithInitialSnapshot,
|
||||
resolveForegroundOpenRequest,
|
||||
} from '../session-open-foreground.ts';
|
||||
|
||||
function baseRequest(overrides: Partial<DaemonRequest> = {}): DaemonRequest {
|
||||
return {
|
||||
token: 't',
|
||||
session: 'default',
|
||||
command: 'open',
|
||||
positionals: [],
|
||||
flags: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resolveSoleForegroundIosApp.mockReset();
|
||||
dispatchSnapshotViaRuntime.mockReset();
|
||||
});
|
||||
|
||||
// --- resolveForegroundOpenRequest ---
|
||||
|
||||
test('leaves the request untouched when --foreground was not requested', async () => {
|
||||
const resolution = await resolveForegroundOpenRequest({
|
||||
req: baseRequest(),
|
||||
hasExistingSession: false,
|
||||
});
|
||||
|
||||
expect(resolution).toEqual({ type: 'not-requested' });
|
||||
expect(resolveSoleForegroundIosApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects --foreground against an existing session', async () => {
|
||||
const resolution = await resolveForegroundOpenRequest({
|
||||
req: baseRequest({ flags: { foreground: true } }),
|
||||
hasExistingSession: true,
|
||||
});
|
||||
|
||||
expect(resolution.type).toBe('response');
|
||||
if (resolution.type === 'response') {
|
||||
expect(resolution.response.ok).toBe(false);
|
||||
if (!resolution.response.ok) {
|
||||
expect(resolution.response.error.code).toBe('INVALID_ARGS');
|
||||
}
|
||||
}
|
||||
expect(resolveSoleForegroundIosApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects --foreground combined with an explicit app argument', async () => {
|
||||
const resolution = await resolveForegroundOpenRequest({
|
||||
req: baseRequest({ flags: { foreground: true }, positionals: ['com.example.demo'] }),
|
||||
hasExistingSession: false,
|
||||
});
|
||||
|
||||
expect(resolution.type).toBe('response');
|
||||
if (resolution.type === 'response') {
|
||||
expect(resolution.response.ok).toBe(false);
|
||||
if (!resolution.response.ok) {
|
||||
expect(resolution.response.error.code).toBe('INVALID_ARGS');
|
||||
}
|
||||
}
|
||||
expect(resolveSoleForegroundIosApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('an unambiguous environment rewrites positionals and pins the resolved device', async () => {
|
||||
const soleBootedDevice = { ...IOS_SIMULATOR, id: 'booted-1', name: 'iPhone 16' };
|
||||
resolveSoleForegroundIosApp.mockResolvedValue({
|
||||
device: soleBootedDevice,
|
||||
app: { bundleId: 'xyz.blueskyweb.app', name: 'Bluesky' },
|
||||
});
|
||||
|
||||
const resolution = await resolveForegroundOpenRequest({
|
||||
req: baseRequest({ flags: { foreground: true, iosSimulatorDeviceSet: '/custom/set' } }),
|
||||
hasExistingSession: false,
|
||||
});
|
||||
|
||||
expect(resolution).toEqual({
|
||||
type: 'resolved',
|
||||
req: baseRequest({
|
||||
flags: {
|
||||
foreground: true,
|
||||
iosSimulatorDeviceSet: '/custom/set',
|
||||
udid: 'booted-1',
|
||||
platform: 'ios',
|
||||
},
|
||||
positionals: ['xyz.blueskyweb.app'],
|
||||
}),
|
||||
});
|
||||
expect(resolveSoleForegroundIosApp).toHaveBeenCalledWith({ simulatorSetPath: '/custom/set' });
|
||||
});
|
||||
|
||||
test('an ambiguous environment fails closed with AMBIGUOUS_MATCH instead of guessing', async () => {
|
||||
resolveSoleForegroundIosApp.mockResolvedValue(undefined);
|
||||
|
||||
const resolution = await resolveForegroundOpenRequest({
|
||||
req: baseRequest({ flags: { foreground: true } }),
|
||||
hasExistingSession: false,
|
||||
});
|
||||
|
||||
expect(resolution.type).toBe('response');
|
||||
if (resolution.type === 'response') {
|
||||
expect(resolution.response.ok).toBe(false);
|
||||
if (!resolution.response.ok) {
|
||||
expect(resolution.response.error.code).toBe('AMBIGUOUS_MATCH');
|
||||
expect(resolution.response.error.details?.reason).toBe('foreground_app_ambiguous');
|
||||
expect(resolution.response.error.details?.hint).toMatch(/agent-device open <app>/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a probe failure fails closed like ambiguity, never silently succeeds', async () => {
|
||||
// The resolver owns the catch-all (see resolveSoleForegroundIosApp): a
|
||||
// rejecting simctl/launchctl probe surfaces as `undefined`, which this
|
||||
// handler must treat exactly like ambiguity — a hard AMBIGUOUS_MATCH, not
|
||||
// a guessed target.
|
||||
resolveSoleForegroundIosApp.mockResolvedValue(undefined);
|
||||
|
||||
const resolution = await resolveForegroundOpenRequest({
|
||||
req: baseRequest({ flags: { foreground: true } }),
|
||||
hasExistingSession: false,
|
||||
});
|
||||
|
||||
expect(resolution.type).toBe('response');
|
||||
if (resolution.type === 'response') {
|
||||
expect(resolution.response.ok).toBe(false);
|
||||
if (!resolution.response.ok) {
|
||||
expect(resolution.response.error.code).toBe('AMBIGUOUS_MATCH');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- composeOpenWithInitialSnapshot ---
|
||||
|
||||
const okOpenResponse: DaemonResponse = { ok: true, data: { session: 'default' } };
|
||||
const failedOpenResponse: DaemonResponse = {
|
||||
ok: false,
|
||||
error: { code: 'AMBIGUOUS_MATCH', message: 'nope' },
|
||||
};
|
||||
|
||||
test('passes a failed open response through untouched', async () => {
|
||||
const result = await composeOpenWithInitialSnapshot({
|
||||
req: baseRequest({ flags: { foreground: true } }),
|
||||
sessionName: 'default',
|
||||
logPath: '/tmp/daemon.log',
|
||||
sessionStore: {} as never,
|
||||
openResponse: failedOpenResponse,
|
||||
});
|
||||
|
||||
expect(result).toBe(failedOpenResponse);
|
||||
expect(dispatchSnapshotViaRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('leaves a successful open response untouched when --foreground was not requested', async () => {
|
||||
const result = await composeOpenWithInitialSnapshot({
|
||||
req: baseRequest(),
|
||||
sessionName: 'default',
|
||||
logPath: '/tmp/daemon.log',
|
||||
sessionStore: {} as never,
|
||||
openResponse: okOpenResponse,
|
||||
});
|
||||
|
||||
expect(result).toBe(okOpenResponse);
|
||||
expect(dispatchSnapshotViaRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('attaches the initial snapshot by delegating to the existing snapshot runtime dispatch', async () => {
|
||||
dispatchSnapshotViaRuntime.mockResolvedValue({ ok: true, data: { nodes: [], truncated: false } });
|
||||
|
||||
const req = baseRequest({ flags: { foreground: true }, positionals: ['xyz.blueskyweb.app'] });
|
||||
const result = await composeOpenWithInitialSnapshot({
|
||||
req,
|
||||
sessionName: 'default',
|
||||
logPath: '/tmp/daemon.log',
|
||||
sessionStore: {} as never,
|
||||
openResponse: okOpenResponse,
|
||||
});
|
||||
|
||||
expect(dispatchSnapshotViaRuntime).toHaveBeenCalledWith({
|
||||
req: { ...req, command: 'snapshot', positionals: [] },
|
||||
sessionName: 'default',
|
||||
logPath: '/tmp/daemon.log',
|
||||
sessionStore: {},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: { session: 'default', snapshot: { nodes: [], truncated: false } },
|
||||
});
|
||||
});
|
||||
|
||||
test('surfaces a snapshot-capture failure instead of the open success', async () => {
|
||||
const snapshotFailure: DaemonResponse = {
|
||||
ok: false,
|
||||
error: { code: 'COMMAND_FAILED', message: 'capture failed' },
|
||||
};
|
||||
dispatchSnapshotViaRuntime.mockResolvedValue(snapshotFailure);
|
||||
|
||||
const result = await composeOpenWithInitialSnapshot({
|
||||
req: baseRequest({ flags: { foreground: true } }),
|
||||
sessionName: 'default',
|
||||
logPath: '/tmp/daemon.log',
|
||||
sessionStore: {} as never,
|
||||
openResponse: okOpenResponse,
|
||||
});
|
||||
|
||||
expect(result).toBe(snapshotFailure);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { resolveSoleForegroundIosApp } from '../ios-app-session-hint.ts';
|
||||
import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts';
|
||||
import type { SessionStore } from '../session-store.ts';
|
||||
import type { DaemonRequest, DaemonResponse } from '../types.ts';
|
||||
import { errorResponse } from './response.ts';
|
||||
|
||||
export type ForegroundOpenResolution =
|
||||
| { type: 'not-requested' }
|
||||
| { type: 'resolved'; req: DaemonRequest }
|
||||
| { type: 'response'; response: DaemonResponse };
|
||||
|
||||
/**
|
||||
* RFC prototype (branch claude/observe-foreground): `open --foreground` collapses the
|
||||
* `snapshot -i` (fails) -> read hint -> `open <bundleId>` -> `snapshot -i` dance into one
|
||||
* call for the common case (one booted iOS simulator, one running app).
|
||||
*
|
||||
* When `--foreground` is set with no explicit app argument on a fresh session, this
|
||||
* auto-resolves the target via `resolveSoleForegroundIosApp` — the exact same
|
||||
* ambiguity-detection probe that enriches the SESSION_NOT_FOUND hint — and rewrites the
|
||||
* request's positionals/flags so the rest of `handleOpenCommand`'s existing new-session
|
||||
* flow (device resolution, surface validation, session creation) runs completely
|
||||
* unmodified against the resolved device + bundle id. Only iOS simulators are handled;
|
||||
* see the PR body for scoped-out platforms.
|
||||
*
|
||||
* Fails closed with AMBIGUOUS_MATCH — never guesses — when the environment is not
|
||||
* unambiguous: zero or multiple booted simulators, zero or multiple running apps, or a
|
||||
* probe failure (the resolver owns the catch-all and reports failure as `undefined`,
|
||||
* never a rejection). This mirrors `buildIosOpenCommandHint`'s no-guessing contract
|
||||
* exactly, because it is built from the same underlying probe.
|
||||
*/
|
||||
export async function resolveForegroundOpenRequest(params: {
|
||||
req: DaemonRequest;
|
||||
hasExistingSession: boolean;
|
||||
}): Promise<ForegroundOpenResolution> {
|
||||
const { req, hasExistingSession } = params;
|
||||
if (req.flags?.foreground !== true) return { type: 'not-requested' };
|
||||
|
||||
if (hasExistingSession) {
|
||||
return {
|
||||
type: 'response',
|
||||
response: errorResponse(
|
||||
'INVALID_ARGS',
|
||||
'open --foreground only applies to a fresh session. Close the current session first, or omit --foreground.',
|
||||
),
|
||||
};
|
||||
}
|
||||
if (req.positionals?.[0]) {
|
||||
return {
|
||||
type: 'response',
|
||||
response: errorResponse(
|
||||
'INVALID_ARGS',
|
||||
'open --foreground cannot be combined with an explicit app argument.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = await resolveSoleForegroundIosApp({
|
||||
simulatorSetPath: req.flags?.iosSimulatorDeviceSet,
|
||||
});
|
||||
if (!resolved) {
|
||||
return {
|
||||
type: 'response',
|
||||
response: errorResponse(
|
||||
'AMBIGUOUS_MATCH',
|
||||
'open --foreground requires an unambiguous environment: exactly one booted iOS simulator with exactly one app running.',
|
||||
{
|
||||
reason: 'foreground_app_ambiguous',
|
||||
hint: 'Pass an explicit app instead: agent-device open <app> --platform ios.',
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'resolved',
|
||||
req: {
|
||||
...req,
|
||||
positionals: [resolved.app.bundleId],
|
||||
flags: { ...req.flags, udid: resolved.device.id, platform: 'ios' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the `open --foreground` composition: once `handleOpenCommand` has
|
||||
* created the session (with `session.appBundleId` now set), attach an initial
|
||||
* interactive snapshot by delegating to the EXISTING `snapshot` runtime dispatch
|
||||
* (`dispatchSnapshotViaRuntime`) — the same ref-issuing, session-store-updating path
|
||||
* `agent-device snapshot -i` uses. This is deliberately composition, not a new capture
|
||||
* pipeline: refs, snapshot lineage, and ref-frame activation all come from the one
|
||||
* capture path already trusted for `snapshot`.
|
||||
*
|
||||
* A no-op when `--foreground` was not requested, or when open itself failed (nothing to
|
||||
* attach a snapshot to).
|
||||
*/
|
||||
export async function composeOpenWithInitialSnapshot(params: {
|
||||
req: DaemonRequest;
|
||||
sessionName: string;
|
||||
logPath: string;
|
||||
sessionStore: SessionStore;
|
||||
openResponse: DaemonResponse;
|
||||
}): Promise<DaemonResponse> {
|
||||
const { req, sessionName, logPath, sessionStore, openResponse } = params;
|
||||
if (!openResponse.ok || req.flags?.foreground !== true) return openResponse;
|
||||
|
||||
const snapshotResponse = await dispatchSnapshotViaRuntime({
|
||||
req: { ...req, command: 'snapshot', positionals: [] },
|
||||
sessionName,
|
||||
logPath,
|
||||
sessionStore,
|
||||
});
|
||||
if (!snapshotResponse.ok) return snapshotResponse;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: { ...openResponse.data, snapshot: snapshotResponse.data },
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
validatePreResolvedOpenRequest,
|
||||
validateResolvedOpenRequest,
|
||||
} from './session-open-prepare.ts';
|
||||
import { resolveForegroundOpenRequest } from './session-open-foreground.ts';
|
||||
import { errorResponse } from './response.ts';
|
||||
import { expireRefFrame } from '../ref-frame.ts';
|
||||
import { buildSessionRecoveryHint } from '../session-recovery-hints.ts';
|
||||
@@ -652,9 +653,16 @@ export async function handleOpenCommand(params: {
|
||||
logPath: string;
|
||||
sessionStore: SessionStore;
|
||||
}): Promise<DaemonResponse> {
|
||||
const { req, sessionName, logPath, sessionStore } = params;
|
||||
const { sessionName, logPath, sessionStore } = params;
|
||||
|
||||
const session = sessionStore.get(sessionName);
|
||||
const foregroundResolution = await resolveForegroundOpenRequest({
|
||||
req: params.req,
|
||||
hasExistingSession: Boolean(session),
|
||||
});
|
||||
if (foregroundResolution.type === 'response') return foregroundResolution.response;
|
||||
const req = foregroundResolution.type === 'resolved' ? foregroundResolution.req : params.req;
|
||||
|
||||
if (session) {
|
||||
if (req.flags?.saveScript) {
|
||||
return errorResponse(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { errorResponse, requireCommandSupported } from './response.ts';
|
||||
import { recordSessionAction } from './handler-utils.ts';
|
||||
import { handleRuntimeCommand } from './session-runtime-command.ts';
|
||||
import { handleOpenCommand } from './session-open.ts';
|
||||
import { composeOpenWithInitialSnapshot } from './session-open-foreground.ts';
|
||||
import {
|
||||
resolveAndroidPackageForOpen,
|
||||
resolveSessionAppBundleIdForTarget,
|
||||
@@ -450,7 +451,13 @@ const SESSION_COMMAND_HANDLER_IMPLS = {
|
||||
push: handlePushCommand,
|
||||
'trigger-app-event': handleTriggerAppEventCommand,
|
||||
open: async ({ req, sessionName, logPath, sessionStore }) =>
|
||||
await handleOpenCommand({ req, sessionName, logPath, sessionStore }),
|
||||
await composeOpenWithInitialSnapshot({
|
||||
req,
|
||||
sessionName,
|
||||
logPath,
|
||||
sessionStore,
|
||||
openResponse: await handleOpenCommand({ req, sessionName, logPath, sessionStore }),
|
||||
}),
|
||||
replay: handleSessionReplayCommandGroup,
|
||||
test: handleSessionReplayCommandGroup,
|
||||
batch: async ({ req, sessionName, invoke }) => await runBatchCommands(req, sessionName, invoke),
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock('../platforms/apple/core/devices.ts', () => ({ listBootedIosSimulators }
|
||||
vi.mock('../platforms/apple/core/app-resolution.ts', () => ({ detectSoleRunningIosSimulatorApp }));
|
||||
|
||||
import { IOS_DEVICE, IOS_SIMULATOR } from '../__tests__/test-utils/index.ts';
|
||||
import { buildIosOpenCommandHint } from './ios-app-session-hint.ts';
|
||||
import { buildIosOpenCommandHint, resolveSoleForegroundIosApp } from './ios-app-session-hint.ts';
|
||||
|
||||
beforeEach(() => {
|
||||
listBootedIosSimulators.mockReset();
|
||||
@@ -122,3 +122,53 @@ test('a physical iOS device never probes for a hint', async () => {
|
||||
expect(hint).toBeUndefined();
|
||||
expect(listBootedIosSimulators).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('resolveSoleForegroundIosApp resolves the device and app when unambiguous', async () => {
|
||||
const soleBootedDevice = { ...IOS_SIMULATOR, id: 'booted-1', name: 'iPhone 16' };
|
||||
const app = { bundleId: 'xyz.blueskyweb.app', name: 'Bluesky' };
|
||||
listBootedIosSimulators.mockResolvedValue([soleBootedDevice]);
|
||||
detectSoleRunningIosSimulatorApp.mockResolvedValue(app);
|
||||
|
||||
const resolved = await resolveSoleForegroundIosApp({ simulatorSetPath: '/custom/set' });
|
||||
|
||||
expect(resolved).toEqual({ device: soleBootedDevice, app });
|
||||
expect(listBootedIosSimulators).toHaveBeenCalledWith({ simulatorSetPath: '/custom/set' });
|
||||
expect(detectSoleRunningIosSimulatorApp).toHaveBeenCalledWith(soleBootedDevice);
|
||||
});
|
||||
|
||||
test('resolveSoleForegroundIosApp returns undefined for zero booted simulators', async () => {
|
||||
listBootedIosSimulators.mockResolvedValue([]);
|
||||
|
||||
expect(await resolveSoleForegroundIosApp()).toBeUndefined();
|
||||
expect(detectSoleRunningIosSimulatorApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('resolveSoleForegroundIosApp returns undefined for more than one booted simulator', async () => {
|
||||
listBootedIosSimulators.mockResolvedValue([
|
||||
{ ...IOS_SIMULATOR, id: 'booted-1' },
|
||||
{ ...IOS_SIMULATOR, id: 'booted-2' },
|
||||
]);
|
||||
|
||||
expect(await resolveSoleForegroundIosApp()).toBeUndefined();
|
||||
expect(detectSoleRunningIosSimulatorApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('resolveSoleForegroundIosApp returns undefined when the running-app probe is inconclusive', async () => {
|
||||
listBootedIosSimulators.mockResolvedValue([{ ...IOS_SIMULATOR, id: 'booted-1' }]);
|
||||
detectSoleRunningIosSimulatorApp.mockResolvedValue(undefined);
|
||||
|
||||
expect(await resolveSoleForegroundIosApp()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('resolveSoleForegroundIosApp catches a rejecting probe instead of propagating it', async () => {
|
||||
// The catch-all lives in the resolver, so both consumers (the hint and
|
||||
// open --foreground) inherit the never-propagate contract from one place.
|
||||
listBootedIosSimulators.mockRejectedValue(new Error('simctl timed out'));
|
||||
|
||||
await expect(resolveSoleForegroundIosApp()).resolves.toBeUndefined();
|
||||
|
||||
listBootedIosSimulators.mockResolvedValue([{ ...IOS_SIMULATOR, id: 'booted-1' }]);
|
||||
detectSoleRunningIosSimulatorApp.mockRejectedValue(new Error('launchctl timed out'));
|
||||
|
||||
await expect(resolveSoleForegroundIosApp()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,22 +1,55 @@
|
||||
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
|
||||
import { detectSoleRunningIosSimulatorApp } from '../platforms/apple/core/app-resolution.ts';
|
||||
import type { IosAppInfo } from '../platforms/apple/core/app-info.ts';
|
||||
import { listBootedIosSimulators } from '../platforms/apple/core/devices.ts';
|
||||
import { shellQuoteIfNeeded } from '../utils/shell-quote.ts';
|
||||
|
||||
export type ResolvedForegroundIosApp = {
|
||||
device: DeviceInfo;
|
||||
app: IosAppInfo;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enriches the generic "Run open first" SESSION_NOT_FOUND hint with the exact
|
||||
* runnable command, but only when the environment is unambiguous: exactly one
|
||||
* booted iOS simulator with exactly one app running on it.
|
||||
* The shared ambiguity-detection probe: exactly one booted iOS simulator with
|
||||
* exactly one app running on it, or `undefined`.
|
||||
*
|
||||
* Any ambiguity — no booted simulator, more than one, no running app, more
|
||||
* than one running app, or a probe failure — returns `undefined` so the
|
||||
* caller keeps the generic hint. This must never guess: a wrong-but-confident
|
||||
* command is worse than the default guidance.
|
||||
* than one running app, or a probe failure — returns `undefined`. This must
|
||||
* never guess: a wrong-but-confident answer is worse than failing closed.
|
||||
*
|
||||
* The whole probe is strictly best-effort: it runs on an error path, so a
|
||||
* throw here (a bounded probe still rejects on timeout or a spawn failure —
|
||||
* see app-resolution.ts) must fall back to `undefined`, never replace the
|
||||
* caller's deterministic error with a probe failure.
|
||||
* Strictly best-effort: a throw here (a bounded probe still rejects on
|
||||
* timeout or a spawn failure — see app-resolution.ts) is caught and treated
|
||||
* as inconclusive, never propagated — callers sit on error/decision paths
|
||||
* where a probe failure must not replace their deterministic outcome.
|
||||
*
|
||||
* `buildIosOpenCommandHint` (error-hint enrichment) and the `open --foreground`
|
||||
* convenience (RFC, #observe-foreground) both compose this single probe
|
||||
* instead of re-deriving the ambiguity rules.
|
||||
*/
|
||||
export async function resolveSoleForegroundIosApp(
|
||||
options: { simulatorSetPath?: string } = {},
|
||||
): Promise<ResolvedForegroundIosApp | undefined> {
|
||||
try {
|
||||
const booted = await listBootedIosSimulators({ simulatorSetPath: options.simulatorSetPath });
|
||||
if (booted.length !== 1) return undefined;
|
||||
const [soleBootedDevice] = booted;
|
||||
if (!soleBootedDevice) return undefined;
|
||||
|
||||
const app = await detectSoleRunningIosSimulatorApp(soleBootedDevice);
|
||||
if (!app) return undefined;
|
||||
|
||||
return { device: soleBootedDevice, app };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches the generic "Run open first" SESSION_NOT_FOUND hint with the exact
|
||||
* runnable command, but only when the environment is unambiguous (see
|
||||
* `resolveSoleForegroundIosApp`, which also owns the never-guess and
|
||||
* never-propagate contract). Ambiguity or a probe failure returns `undefined`
|
||||
* so the caller keeps the generic hint.
|
||||
*/
|
||||
// Wire-level details are redacted before send (packages/kernel/src/redaction.ts),
|
||||
// which silently truncates any string field over 400 chars — a truncated
|
||||
@@ -29,23 +62,14 @@ const MAX_HINT_LENGTH = 350;
|
||||
export async function buildIosOpenCommandHint(device: DeviceInfo): Promise<string | undefined> {
|
||||
if (!isIosFamily(device) || device.kind !== 'simulator') return undefined;
|
||||
|
||||
try {
|
||||
const booted = await listBootedIosSimulators({ simulatorSetPath: device.simulatorSetPath });
|
||||
if (booted.length !== 1) return undefined;
|
||||
const [soleBootedDevice] = booted;
|
||||
if (!soleBootedDevice) return undefined;
|
||||
const resolved = await resolveSoleForegroundIosApp({ simulatorSetPath: device.simulatorSetPath });
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const app = await detectSoleRunningIosSimulatorApp(soleBootedDevice);
|
||||
if (!app) return undefined;
|
||||
|
||||
const command = buildOpenCommand(soleBootedDevice, app.bundleId);
|
||||
const hint =
|
||||
`One booted device found ("${soleBootedDevice.name}", udid ${soleBootedDevice.id}) with ` +
|
||||
`${app.bundleId} running. Run: ${command}`;
|
||||
return hint.length <= MAX_HINT_LENGTH ? hint : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const command = buildOpenCommand(resolved.device, resolved.app.bundleId);
|
||||
const hint =
|
||||
`One booted device found ("${resolved.device.name}", udid ${resolved.device.id}) with ` +
|
||||
`${resolved.app.bundleId} running. Run: ${command}`;
|
||||
return hint.length <= MAX_HINT_LENGTH ? hint : undefined;
|
||||
}
|
||||
|
||||
// Always pins --udid: the sole-booted-device check only proves there is one
|
||||
|
||||
@@ -164,6 +164,7 @@ export function serializeOpenResult(result: AppOpenResult): Record<string, unkno
|
||||
...(result.startup ? { startup: result.startup } : {}),
|
||||
...(result.runtime ? { runtime: result.runtime } : {}),
|
||||
...(result.device ? serializeSessionDevice(result.device) : {}),
|
||||
...(result.snapshot ? { snapshot: result.snapshot } : {}),
|
||||
},
|
||||
target ? `Opened: ${target}` : 'Opened',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user