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
4 changes: 2 additions & 2 deletions docs/adr/0014-session-ref-frame-lifetime.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,15 @@ classifying representative actions; it must not become a second prose registry:

| Effect | Commands/actions |
| --- | --- |
| `may-invalidate` | press, click, fill, longpress, type, focus, scroll, swipe, gesture, back, home, `tv-remote`, rotate, open/relaunch, trigger/push delivery, settings changes, install/reinstall, React Native overlay dismissal, and lifecycle operations that can replace the visible surface |
| `may-invalidate` | press, click, fill, longpress, type, focus, scroll, swipe, gesture, back, home, `tv-remote`, orientation, open/relaunch, trigger/push delivery, settings changes, install/reinstall, React Native overlay dismissal, and lifecycle operations that can replace the visible surface |
| Conditional resolver | keyboard status preserves while dismiss/return/input invalidate; alert get/wait preserve while accept/dismiss invalidate; find reads preserve while click/fill/focus/type delegate to their leaf mutation |
| `delegated` | batch, replay, and test/suite orchestrators; each nested leaf owns its transition |
| `preserve` | snapshots and other observation, assertion, screenshot, recording, trace, logs, events, network inspection, performance, inventory, capability, lease, and transport-management operations unless a selected subaction directly manipulates the visible surface |

Clipboard reads and writes preserve the frame because pasteboard state alone does not change element
identity. A later paste/type action is independently invalidating.

Generic routing is not an exception to the policy. `back`, `home`, `rotate`, `scroll`, `tv-remote`,
Generic routing is not an exception to the policy. `back`, `home`, `orientation`, `scroll`, `tv-remote`,
and `app-switcher` all reach the generic daemon leaf and must cross the same transition there when
they act. `app-switcher` is currently projected to the daemon by a direct writer but, unlike its
generic-routed siblings, omits an explicit daemon descriptor facet and relies on the registry's
Expand Down
35 changes: 35 additions & 0 deletions src/__tests__/batch-command-alias.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { readCliBatchStepsJson } from '../cli/batch-steps.ts';
import { readStructuredBatchCommandName, normalizeBatchCommandName } from '../batch-policy.ts';

// Batch steps carry the wire command in structured data that never passes through
// the CLI token parser, so the shared command-alias map is applied where each
// batch layer first reads the command name. This keeps shipped batch data that
// still says `rotate` resolving to canonical `orientation` instead of failing as
// "not available through command batch".

test('CLI batch parser resolves a deprecated rotate step to orientation', () => {
const [step] = readCliBatchStepsJson(
JSON.stringify([{ command: 'rotate', positionals: ['portrait'] }]),
);
assert.equal(step?.command, 'orientation');
});

test('CLI batch parser resolves a deprecated rotate alias case-insensitively', () => {
const [step] = readCliBatchStepsJson(
JSON.stringify([{ command: 'ROTATE', input: { orientation: 'landscape-left' } }]),
);
assert.equal(step?.command, 'orientation');
});

test('daemon batch policy accepts and normalizes a deprecated rotate step', () => {
assert.equal(normalizeBatchCommandName('rotate'), 'orientation');
assert.equal(readStructuredBatchCommandName('rotate', 1), 'orientation');
});

test('daemon batch policy leaves canonical and unknown names intact', () => {
assert.equal(normalizeBatchCommandName('orientation'), 'orientation');
assert.equal(normalizeBatchCommandName('press'), 'press');
assert.throws(() => readStructuredBatchCommandName('not-a-command', 1), /not available/);
});
9 changes: 9 additions & 0 deletions src/__tests__/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,15 @@ test('launch dispatches as a plain open without forcing a relaunch', async () =>
assert.notEqual(result.calls[0]?.flags?.relaunch, true);
});

test('rotate dispatches as orientation (deprecated alias) with positionals preserved', async () => {
const result = await runCliCapture(['rotate', 'landscape-left', '--json']);
assert.doesNotMatch(result.stderr, /Unknown command/);
// Canonicalization: the daemon call must record orientation, never rotate.
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.command, 'orientation');
assert.deepEqual(result.calls[0]?.positionals, ['landscape-left']);
});

// From #1052 (credit: @vku2018): the alias must compose with the bare-ref
// hint — `tap e3` normalizes to press, then gets the @e3 suggestion.
test('tap with a bare ref gets the @ref hint, not an unknown-command error', async () => {
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,26 @@ test('client exposes narrowed result types for closed daemon projections', async
assert.equal(triggerResult.transport, 'deep-link');
});

test('deprecated client.command.rotate delegates to orientation and keeps the legacy action', async () => {
const setup = createTransport(async () => ({
ok: true,
data: {
action: 'orientation',
orientation: 'landscape-left',
message: 'Rotated to landscape-left',
},
}));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

const result = await client.command.rotate({ orientation: 'landscape-left' });

// The wrapper sends the canonical wire command...
assert.equal(setup.calls.at(-1)?.command, 'orientation');
// ...and restores the shipped v0.18/v0.19 response contract for old consumers.
assert.equal(result.action, 'rotate');
assert.equal(result.orientation, 'landscape-left');
});

function closedProjectionResponse(command: string): DaemonResponse {
const data = closedProjectionResponses[command];
if (!data) throw new Error(`Unexpected command: ${command}`);
Expand Down
12 changes: 6 additions & 6 deletions src/__tests__/contracts-schema-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
AppSwitcherCommandResult,
BackCommandResult,
HomeCommandResult,
RotateCommandResult,
OrientationCommandResult,
TvRemoteCommandResult,
} from '../contracts/navigation.ts';
import type { ViewportCommandResult } from '../contracts/viewport.ts';
Expand Down Expand Up @@ -121,12 +121,12 @@ test('command result contracts are assignable to command result map', () => {
} satisfies AppSwitcherCommandResult;
const appSwitcherFromMap: CommandResult<'app-switcher'> = appSwitcher;

const rotate = {
action: 'rotate',
const orientation = {
action: 'orientation',
orientation: 'portrait',
message: 'Rotated to portrait',
} satisfies RotateCommandResult;
const rotateFromMap: CommandResult<'rotate'> = rotate;
} satisfies OrientationCommandResult;
const orientationFromMap: CommandResult<'orientation'> = orientation;

const tvRemote = {
action: 'tv-remote',
Expand Down Expand Up @@ -155,7 +155,7 @@ test('command result contracts are assignable to command result map', () => {
assert.equal(backFromMap.mode, 'in-app');
assert.equal(homeFromMap.action, 'home');
assert.equal(appSwitcherFromMap.action, 'app-switcher');
assert.equal(rotateFromMap.orientation, 'portrait');
assert.equal(orientationFromMap.orientation, 'portrait');
assert.equal(tvRemoteFromMap.button, 'select');
assert.equal(clipboardFromMap.action === 'write' ? clipboardFromMap.textLength : -1, 11);
assert.equal(
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/runtime-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ test('internal backend, commands, and io modules are usable', () => {
assert.equal(typeof commands.interactions.gesture, 'function');
assert.equal(typeof commands.system.back, 'function');
assert.equal(typeof commands.system.home, 'function');
assert.equal(typeof commands.system.orientation, 'function');
assert.equal(typeof commands.system.rotate, 'function');
assert.equal(typeof commands.system.keyboard, 'function');
assert.equal(typeof commands.system.clipboard, 'function');
Expand Down
15 changes: 14 additions & 1 deletion src/agent-device-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ import type {
MaterializationReleaseOptions,
MetroPrepareOptions,
MetroPrepareResult,
OrientationCommandResult,
PanOptions,
FlingOptions,
RotateCommandResult,
SwipeGestureOptions,
PinchOptions,
RotateGestureOptions,
Expand All @@ -74,7 +76,7 @@ import type { ProjectedNavigationCommandClient } from './commands/system/navigat
import { AppError } from './kernel/errors.ts';

type ProjectedSystemCommandClient = ProjectedNavigationCommandClient<InternalRequestOptions> &
Pick<AgentDeviceCommandClient, 'appState' | 'keyboard' | 'clipboard'>;
Pick<AgentDeviceCommandClient, 'appState' | 'keyboard' | 'clipboard' | 'rotate'>;

export function createAgentDeviceClient(
config: AgentDeviceClientConfig = {},
Expand Down Expand Up @@ -520,6 +522,17 @@ function buildProjectedSystemCommandClient(
methods[method] = async (options = {}) =>
await executeCommand<CommandResult<typeof command>>(command as DaemonCommandName, options);
}
// Deprecated (v0.18/v0.19): `rotate` was renamed to `orientation`. Retain a
// thin wrapper that delegates to `orientation` and restores the legacy
// `action: 'rotate'` response contract for existing consumers.
const orientation = methods.orientation;
if (!orientation) {
throw new Error('orientation client method missing from the system command family');
}
methods.rotate = async (options = {}) => {
const result = (await orientation(options)) as OrientationCommandResult;
return { ...result, action: 'rotate' } satisfies RotateCommandResult;
};
return methods as unknown as ProjectedSystemCommandClient;
}

Expand Down
2 changes: 1 addition & 1 deletion src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ export type AgentDeviceBackend = {
context: BackendCommandContext,
options: BackendTvRemoteOptions,
): Promise<BackendActionResult>;
rotate?(
setOrientation?(
context: BackendCommandContext,
orientation: BackendDeviceOrientation,
): Promise<BackendActionResult>;
Expand Down
7 changes: 6 additions & 1 deletion src/batch-policy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { deriveStructuredBatchCommandNames } from './core/command-descriptor/derive.ts';
import { commandDescriptors } from './core/command-descriptor/registry.ts';
import { normalizeCommandAlias } from './command-aliases.ts';
import { AppError } from './kernel/errors.ts';

/**
Expand Down Expand Up @@ -57,7 +58,11 @@ function isStructuredBatchCommandName(command: string): command is StructuredBat
}

export function normalizeBatchCommandName(command: unknown): string {
return typeof command === 'string' ? command.trim().toLowerCase() : '';
// Resolve command-name aliases (e.g. `rotate` -> `orientation`) from the shared
// map so structured batch data carrying a renamed command validates and runs
// against the canonical descriptor instead of failing as unavailable.
const raw = typeof command === 'string' ? command.trim().toLowerCase() : '';
return raw ? normalizeCommandAlias(raw) : '';
}

export function readStructuredBatchCommandName(
Expand Down
35 changes: 0 additions & 35 deletions src/cli-command-aliases.ts

This file was deleted.

9 changes: 7 additions & 2 deletions src/cli/batch-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type SessionRuntimeHints } from '../kernel/contracts.ts';
import { parseBatchStepRuntime } from '../batch-contract.ts';
import { readInputFromCli } from '../commands/cli-grammar.ts';
import { isCommandName, type CommandName } from '../commands/command-metadata.ts';
import { normalizeCommandAlias } from '../command-aliases.ts';
import type { CliFlags } from '../commands/cli-grammar/flag-types.ts';
import { AppError } from '../kernel/errors.ts';
import { isRecord } from '../utils/parsing.ts';
Expand Down Expand Up @@ -68,6 +69,8 @@ function readStructuredBatchStep(
const { runtime: _runtime, ...rest } = step;
return {
...rest,
// Resolve command-name aliases (e.g. `rotate` -> `orientation`) from the shared map.
command: normalizeCommandAlias(rest.command),
...(runtime === undefined ? {} : { runtime }),
};
}
Expand All @@ -90,8 +93,10 @@ function readLegacyCliBatchStep(step: unknown, stepNumber: number): LegacyCliBat
}

function readLegacyCommand(value: unknown, stepNumber: number): CommandName {
const command = typeof value === 'string' ? value.trim().toLowerCase() : '';
if (!command) throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} requires command.`);
const raw = typeof value === 'string' ? value.trim().toLowerCase() : '';
if (!raw) throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} requires command.`);
// Resolve command-name aliases (e.g. `rotate` -> `orientation`) from the shared map.
const command = normalizeCommandAlias(raw);
if (isCommandName(command)) return command;
throw new AppError(
'INVALID_ARGS',
Expand Down
10 changes: 8 additions & 2 deletions src/cli/parser/__tests__/args-parse-interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,15 @@ test('parseArgs supports trigger-app-event payload argument', () => {
assert.deepEqual(parsed.positionals, ['screenshot_taken', '{"source":"qa"}']);
});

test('parseArgs accepts rotate orientation aliases', () => {
test('parseArgs accepts the orientation command', () => {
const parsed = parseArgs(['orientation', 'left'], { strictFlags: true });
assert.equal(parsed.command, 'orientation');
assert.deepEqual(parsed.positionals, ['left']);
});

test('parseArgs normalizes the deprecated rotate alias to orientation', () => {
const parsed = parseArgs(['rotate', 'left'], { strictFlags: true });
assert.equal(parsed.command, 'rotate');
assert.equal(parsed.command, 'orientation');
assert.deepEqual(parsed.positionals, ['left']);
});

Expand Down
18 changes: 15 additions & 3 deletions src/cli/parser/__tests__/cli-help-command-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,11 +372,23 @@ test('keyboard command usage is documented', async () => {
);
});

test('rotate command usage is documented', async () => {
test('orientation command usage is documented', async () => {
const help = await usageForCommand('orientation');
if (help === null) throw new Error('Expected command help text');
assert.match(
help,
/orientation <portrait\|portrait-upside-down\|landscape-left\|landscape-right>/,
);
assert.match(help, /Set device orientation on iOS and Android/);
});

test('deprecated rotate alias resolves to orientation usage', async () => {
const help = await usageForCommand('rotate');
if (help === null) throw new Error('Expected command help text');
assert.match(help, /rotate <portrait\|portrait-upside-down\|landscape-left\|landscape-right>/);
assert.match(help, /Rotate device orientation on iOS and Android/);
assert.match(
help,
/orientation <portrait\|portrait-upside-down\|landscape-left\|landscape-right>/,
);
});

test('settings usage documents canonical faceid states', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/parser/__tests__/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ test('usage includes concise top-level commands', async () => {
assert.doesNotMatch(usageText, /^ fling <up\|down\|left\|right>/m);
assert.doesNotMatch(usageText, /^ pinch <scale> \[x\] \[y\]/m);
assert.doesNotMatch(usageText, /^ rotate-gesture <degrees>/m);
assert.match(usageText, /rotate <orientation>/);
assert.match(usageText, /orientation <orientation>/);
assert.match(usageText, /record start \[path\] \| record stop/);
assert.match(usageText, /trace start <path> \| trace stop <path>/);
});
Expand Down
6 changes: 3 additions & 3 deletions src/cli/parser/__tests__/command-suggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ test('the keyboard dismiss example uses a real keyboard action', () => {
assert.doesNotThrow(() => keyboardCliReader(['dismiss'], baseFlags));
});

// `launch`/`relaunch` (and `tap`) are true aliases normalized before the
// unknown-command check, so they must never appear in the suggestion map —
// `launch`/`relaunch`, `tap`, and `rotate` are true aliases normalized before
// the unknown-command check, so they must never appear in the suggestion map —
// a stale entry there would be dead code masking the alias.
test('true aliases are not listed in the curated suggestion map', () => {
const guesses = new Set(listCommandAliasSuggestionEntries().map(([guess]) => guess));
for (const alias of ['launch', 'relaunch', 'tap']) {
for (const alias of ['launch', 'relaunch', 'tap', 'rotate']) {
assert.ok(!guesses.has(alias), `"${alias}" is a true alias and must not be a suggestion`);
}
});
Expand Down
8 changes: 2 additions & 6 deletions src/cli/parser/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '../../cli-schema/command-schema.ts';
import { isFlagSupportedForCommand } from '../../cli-schema/option-schema.ts';
import { isKnownCliCommandName } from '../../command-catalog.ts';
import { cliCommandAlias, normalizeCliCommandAlias } from '../../cli-command-aliases.ts';
import { commandAlias, normalizeCommandAlias } from '../../command-aliases.ts';
import { formatUnknownFlagMessage, suggestCommandFor } from './command-suggestions.ts';

type ParsedArgs = {
Expand Down Expand Up @@ -118,7 +118,7 @@ export function parseRawArgs(argv: string[]): RawParsedArgs {

function applyAliasImpliedFlags(rawCommand: string | null, flags: CliFlags): void {
if (!rawCommand) return;
for (const key of cliCommandAlias(rawCommand)?.impliedFlags ?? []) {
for (const key of commandAlias(rawCommand)?.impliedFlags ?? []) {
flags[key] = true;
}
}
Expand Down Expand Up @@ -382,7 +382,3 @@ export async function usageForCommand(command: string): Promise<string | null> {
const { buildCommandUsageText } = await import('./cli-help.ts');
return buildCommandUsageText(normalizeCommandAlias(command));
}

function normalizeCommandAlias(command: string): string {
return normalizeCliCommandAlias(command);
}
5 changes: 3 additions & 2 deletions src/cli/parser/command-suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { listCliCommandNames } from '../../command-catalog.ts';
* parse as a valid invocation of it; the registry-drift tests in
* `src/cli/parser/__tests__/command-suggestions.test.ts` fail the build on drift.
*
* True aliases (`tap` -> press, `launch`/`relaunch` -> open) are normalized
* case-insensitively in `normalizeCommandAlias` (args.ts) before the
* True aliases (`tap` -> press, `launch`/`relaunch` -> open, `rotate` ->
* orientation) are normalized case-insensitively in `normalizeCommandAlias`
* (args.ts) before the
* unknown-command check runs, so they never reach this map and must not be
* listed here. `start`/`restart` stay suggestion-only: `start` is genuinely
* ambiguous, so a hint beats silently guessing.
Expand Down
Loading
Loading