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
37 changes: 37 additions & 0 deletions packages/kernel/src/device-selector-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,43 @@ test('an unspecified platform keeps the existing device-not-found behavior', asy
assert.equal(error.code, 'DEVICE_NOT_FOUND');
});

// `--device` takes a NAME. Passing a UDID there answered "No device named <udid>" with the generic
// booted/connected hint (#2064) — true, unactionable, and silent about `--udid` existing at all.

test('a UDID passed to --device names --udid and the device it identifies', async () => {
const error = await resolveError([APPLE, ANDROID], { deviceName: 'SIM-001' });
assert.equal(error.code, 'DEVICE_NOT_FOUND');
assert.match(error.message, /No device named SIM-001/);
assert.match(
String(error.details?.hint ?? ''),
/SIM-001 is the id of "iPhone 16", not its name\. Did you mean --udid SIM-001\?/,
);
});

test('a serial passed to --device names --serial, not --udid', async () => {
const error = await resolveError([APPLE, ANDROID], { deviceName: 'emulator-5580' });
assert.equal(error.code, 'DEVICE_NOT_FOUND');
assert.match(String(error.details?.hint ?? ''), /Did you mean --serial emulator-5580\?/);
});

test('a UDID-shaped --device value names --udid even when no listed device has that id', async () => {
const error = await resolveError([APPLE], {
platform: 'ios',
deviceName: '204BFFD9-9644-4830-B2C1-1B946597A07C',
});
assert.equal(error.code, 'DEVICE_NOT_FOUND');
assert.match(
String(error.details?.hint ?? ''),
/--device takes a device name\. Did you mean --udid 204BFFD9-9644-4830-B2C1-1B946597A07C\?/,
);
});

test('an ordinary unknown --device name keeps the generic device-not-found hint', async () => {
const error = await resolveError([APPLE], { platform: 'ios', deviceName: 'iPhone 99' });
assert.equal(error.code, 'DEVICE_NOT_FOUND');
assert.equal(error.details?.hint, undefined);
});

async function resolveError(
devices: DeviceInfo[],
selector: Parameters<typeof resolveDevice>[1],
Expand Down
40 changes: 39 additions & 1 deletion packages/kernel/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,48 @@ function resolveDeviceByName(
if (!deviceName) return undefined;
const normalizedName = normalizeDeviceName(deviceName);
const match = candidates.find((device) => normalizeDeviceName(device.name) === normalizedName);
if (!match) throw new AppError('DEVICE_NOT_FOUND', `No device named ${deviceName}`);
if (!match) {
const hint = deviceIdentityMistakenForNameHint(candidates, deviceName);
throw new AppError(
'DEVICE_NOT_FOUND',
`No device named ${deviceName}`,
hint === undefined ? undefined : { hint },
);
}
return match;
}

// A simulator/device UDID: five hex groups, 8-4-4-4-12. Only used to recognize a UDID the
// inventory does not currently list (a shut-down or unplugged one), so the answer is still
// "wrong flag" rather than "no such device".
const DEVICE_UDID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
* `--device` takes a device NAME, and `--udid`/`--serial` take a device IDENTITY. Passing an
* identity to `--device` reached name resolution and answered "No device named
* 204BFFD9-9644-4830-B2C1-1B946597A07C" (#2064) — literally true, and unactionable: it names
* neither the flag that does take that value nor the fact that one exists. It is the same class of
* mistake `assertSelectorFlagMatchesPlatform` already answers for a mismatched identity flag, so
* answer it the same way: name the flag the value belongs to.
*/
function deviceIdentityMistakenForNameHint(
candidates: DeviceInfo[],
deviceName: string,
): string | undefined {
const identityMatch = candidates.find((device) => device.id === deviceName);
if (identityMatch) {
const flag = isSerialAddressablePlatform(identityMatch.platform) ? '--serial' : '--udid';
return (
`${deviceName} is the id of ${JSON.stringify(identityMatch.name)}, not its name. ` +
`Did you mean ${flag} ${deviceName}?`
);
}
if (DEVICE_UDID_SHAPE.test(deviceName)) {
return `--device takes a device name. Did you mean --udid ${deviceName}?`;
}
return undefined;
}

/**
* SINGULAR RESOLUTION. Every caller of `resolveDevice` needs exactly one concrete device, so when
* the request carries no device identity and more than one candidate survives the preference tiers,
Expand Down
17 changes: 17 additions & 0 deletions src/cli-schema/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ test('gesture help documents selectors and pinned refs for both drag endpoints',
assert.match(help, /drag <source-selector\|pinned-ref> <destination-selector\|pinned-ref>/);
});

// `--udid` was reachable only from `help device`'s usage line, so the one flag that pins a
// specific simulator among several sharing a name was invisible in the command catalog (#2064).
test('commands topic lists the device selectors accepted by every command', async () => {
const usageText = await usageForCommand('commands');
if (usageText === null) throw new Error('Expected commands help text');
const selectionSection = usageText.slice(
usageText.indexOf('Device Selection'),
usageText.indexOf('Global Flags:'),
);
assert.match(selectionSection, /^Device Selection/);
assert.match(selectionSection, /--platform /);
assert.match(selectionSection, /--device <name>/);
assert.match(selectionSection, /--udid <udid>/);
assert.match(selectionSection, /--serial <serial>/);
assert.match(selectionSection, /--session <name>/);
});

test('commands topic includes only global flags in its global flags section', async () => {
const usageText = await usageForCommand('commands');
if (usageText === null) throw new Error('Expected commands help text');
Expand Down
7 changes: 7 additions & 0 deletions src/cli-schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@agent-device/maestro';
import { helpBody } from '../commands/command-text.ts';
import {
DEVICE_SELECTION_FLAG_KEYS,
getCliCommandSchema,
getCommandSchema,
getFlagDefinitions,
Expand Down Expand Up @@ -1059,6 +1060,10 @@ Full command catalog. Use agent-device help <command> for exact flags and behavi
});
const commandLines = renderCommandSection(commands);

const selectionSection = renderFlagSection(
'Device Selection (accepted by every device command):',
listHelpFlags(DEVICE_SELECTION_FLAG_KEYS),
);
const helpFlags = listHelpFlags(GLOBAL_FLAG_KEYS);
const flagsSection = renderFlagSection('Global Flags:', helpFlags);
const configSection = renderTextSection('Configuration:', CONFIGURATION_LINES);
Expand All @@ -1068,6 +1073,8 @@ Full command catalog. Use agent-device help <command> for exact flags and behavi
return `${header}
${commandLines}

${selectionSection}

${flagsSection}

${configSection}
Expand Down
3 changes: 2 additions & 1 deletion src/cli-schema/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import { getCliCommandOverride, getSchemaOnlyCliCommandSchema } from './command-
import { getFlagDefinition, getFlagDefinitions } from '../commands/cli-grammar/flag-registry.ts';
import {
COMMON_COMMAND_SUPPORTED_FLAG_KEYS,
DEVICE_SELECTION_FLAG_KEYS,
GLOBAL_FLAG_KEYS,
} from '../commands/cli-grammar/flag-groups.ts';
import { type FlagDefinition, type FlagKey } from '../commands/cli-grammar/flag-types.ts';
import { AppError } from '@agent-device/kernel/errors';

export type { FlagDefinition, FlagKey };
export type { CommandSchema };
export { getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS };
export { DEVICE_SELECTION_FLAG_KEYS, getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS };

// Bases hold only the flags every command supports; prose arrives with the facet's schema,
// which always carries a complete `text`.
Expand Down
5 changes: 3 additions & 2 deletions src/commands/cli-grammar/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
names: ['--device'],
type: 'string',
usageLabel: '--device <name>',
usageDescription: 'Device name to target',
usageDescription: 'Device name to target (a UDID belongs in --udid, a serial in --serial)',
},
{
key: 'udid',
names: ['--udid'],
type: 'string',
usageLabel: '--udid <udid>',
usageDescription: 'iOS device UDID',
usageDescription:
'Apple device or simulator UDID; the only selector that pins one device when several share a --device name',
},
{
key: 'serial',
Expand Down
16 changes: 16 additions & 0 deletions src/commands/cli-grammar/flag-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ export const COMMON_COMMAND_SUPPORTED_FLAG_KEYS = flagKeys(
'noRecord',
);

/**
* The device/session selectors every command accepts (they are part of
* {@link COMMON_COMMAND_SUPPORTED_FLAG_KEYS}, not of {@link GLOBAL_FLAG_KEYS}, because they reach
* device resolution rather than the CLI envelope). `help commands` renders them as their own
* section: `--udid` used to appear only inside `help device`'s usage line, so the one flag that
* pins a specific simulator among several with the same name was undiscoverable from the command
* catalog (#2064).
*/
export const DEVICE_SELECTION_FLAG_KEYS: ReadonlySet<FlagKey> = new Set([
'platform',
'device',
'udid',
'serial',
'session',
]);

export const GLOBAL_FLAG_KEYS: ReadonlySet<FlagKey> = new Set([
'json',
'config',
Expand Down