Skip to content
Closed
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
9 changes: 5 additions & 4 deletions src/__tests__/remote-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,6 @@ test('connect proxy writes normal remote state with generated non-secret profile
assert.equal(state.leaseId, undefined);
assert.deepEqual(state.daemon, {
baseUrl: 'http://proxy.example.test/agent-device',
authToken: 'proxy-secret',
transport: 'http',
});
assert.match(state.remoteConfigPath, /remote-connections\/generated\/proxy-[a-f0-9]{16}\.json$/);
Expand Down Expand Up @@ -277,7 +276,6 @@ test('connect daemon-base-url shortcut uses proxy profile for direct proxy URLs'
assert.match(state.clientId ?? '', /^[a-f0-9]{16}$/);
assert.deepEqual(state.daemon, {
baseUrl: 'http://127.0.0.1:4310/agent-device',
authToken: 'proxy-secret',
transport: 'http',
});
assert.equal(state.leaseId, undefined);
Expand Down Expand Up @@ -2269,7 +2267,6 @@ test('disconnect releases proxy lease with provider client and device metadata',
leaseId: 'abc123abc123abc1',
daemon: {
baseUrl: 'http://proxy.example.test/agent-device',
authToken: 'proxy-secret',
},
leaseBackend: 'ios-instance',
leaseProvider: 'proxy',
Expand All @@ -2290,6 +2287,10 @@ test('disconnect releases proxy lease with provider client and device metadata',
version: false,
stateDir,
shutdown: true,
// Not persisted on connection state (ADR 0007): the caller supplies
// it per-command via flag/env/CLI-session, mirroring how the CLI
// dispatcher resolves it before invoking this handler.
daemonAuthToken: 'test-not-a-real-token',
},
client: createTestClient({
release: async (request) => {
Expand All @@ -2306,7 +2307,7 @@ test('disconnect releases proxy lease with provider client and device metadata',
assert.equal(releaseRequest?.leaseId, 'abc123abc123abc1');
assert.equal(releaseRequest?.leaseBackend, 'ios-instance');
assert.equal(releaseRequest?.daemonBaseUrl, 'http://proxy.example.test/agent-device');
assert.equal(releaseRequest?.daemonAuthToken, 'proxy-secret');
assert.equal(releaseRequest?.daemonAuthToken, 'test-not-a-real-token');
assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-proxy' }), null);
fs.rmSync(tempRoot, { recursive: true, force: true });
});
Expand Down
10 changes: 3 additions & 7 deletions src/bin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeCliCommandAlias } from './commands/cli-command-aliases.ts';

const argv = process.argv.slice(2);

declare const __AGENT_DEVICE_VERSION__: string;
Expand Down Expand Up @@ -54,7 +56,7 @@ function runHelpFastPath(argv: string[]): boolean {
process.stdout.write(`${buildUsageText()}\n`);
return;
}
const commandHelp = buildCommandUsageText(normalizeHelpTarget(helpTarget));
const commandHelp = buildCommandUsageText(normalizeCliCommandAlias(helpTarget));
if (commandHelp) {
process.stdout.write(commandHelp);
return;
Expand Down Expand Up @@ -97,12 +99,6 @@ function resolveTrailingHelpTarget(
return isHelpFlag(helpArg) ? command : undefined;
}

function normalizeHelpTarget(command: string): string {
if (command === 'long-press') return 'longpress';
if (command === 'metrics') return 'perf';
return command;
}

function isHelpCommand(command: string | undefined): boolean {
return command === 'help';
}
Expand Down
8 changes: 6 additions & 2 deletions src/cli/commands/connection-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ export async function stopReactDevtoolsCleanup(options: {
export async function releaseRemoteConnectionLease(
client: AgentDeviceClient,
state: RemoteConnectionState,
// The daemon bearer token is never persisted on `state` (ADR 0007); callers
// pass the token already resolved via the flag/env/CLI-session chain.
daemonAuthToken?: string,
): Promise<{ released: boolean; provider?: CloudProviderSessionResult }> {
if (!state.leaseId) return { released: false };
const result = await client.leases.release({
Expand All @@ -491,7 +494,7 @@ export async function releaseRemoteConnectionLease(
leaseId: state.leaseId,
leaseBackend: state.leaseBackend,
daemonBaseUrl: state.daemon?.baseUrl,
daemonAuthToken: state.daemon?.authToken,
daemonAuthToken,
daemonTransport: state.daemon?.transport,
daemonServerMode: state.daemon?.serverMode,
leaseProvider: state.leaseProvider,
Expand All @@ -504,10 +507,11 @@ export async function releaseRemoteConnectionLease(
export async function releasePreviousLease(
client: AgentDeviceClient,
previous: RemoteConnectionState,
daemonAuthToken?: string,
): Promise<void> {
if (!previous.leaseId) return;
try {
await releaseRemoteConnectionLease(client, previous);
await releaseRemoteConnectionLease(client, previous, daemonAuthToken);
} catch {
// Reconnect must succeed even if the old lease was already released.
}
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ async function cleanupForcedPreviousConnection(
if (!previous || !flags.force) return;
await stopMetroCleanup(previous.metro);
await stopReactDevtoolsCleanup({ stateDir, state: previous });
await releasePreviousLease(client, previous);
await releasePreviousLease(client, previous, flags.daemonAuthToken);
}

function readRemoteConfigConnectionMetadata(
Expand Down Expand Up @@ -286,7 +286,7 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client })
let released = false;
if (state.leaseId) {
try {
const release = await releaseRemoteConnectionLease(client, state);
const release = await releaseRemoteConnectionLease(client, state, flags.daemonAuthToken);
released = release.released;
providerData ??= release.provider;
} catch {
Expand Down
66 changes: 66 additions & 0 deletions src/cli/parser/__tests__/cli-help-alias-fast-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Pins the exact composition `bin.ts`'s `--help` fast path relies on:
// `buildCommandUsageText(normalizeCliCommandAlias(helpTarget))`. Before this
// fix, `bin.ts` used its own hand-written two-entry table instead of this
// composition, so `tap`, `launch`, and `relaunch` silently missed the fast
// path and fell through to a full CLI bootstrap just to print static help
// text. `bin.ts` runs unguarded top-level dispatch on import (and is
// deliberately excluded from coverage — see vitest.config.ts), so it cannot
// be imported directly in a test; these tests instead pin the registry
// composition it calls. That makes them a real regression pin for a *future*
// alias missing help text (test 2 is durable for that), but not a substitute
// for the manual proof, run outside this suite, that bin.ts itself calls
// this composition (see the plan's execution report for the red/green
// evidence: with the stale table, `tap --help` loads `src/cli.ts`; with this
// fix, it does not).
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { buildCommandUsageText } from '../cli-help.ts';
import {
cliAliasesForCommand,
normalizeCliCommandAlias,
} from '../../../commands/cli-command-aliases.ts';
import { listCliCommandNames } from '../../../command-catalog.ts';

test('alias help output matches its canonical command', () => {
const cases: ReadonlyArray<readonly [string, string]> = [
['tap', 'press'],
['launch', 'open'],
['relaunch', 'open'],
['long-press', 'longpress'],
['metrics', 'perf'],
];
for (const [alias, canonical] of cases) {
const aliasHelp = buildCommandUsageText(normalizeCliCommandAlias(alias));
const canonicalHelp = buildCommandUsageText(canonical);
assert.notEqual(aliasHelp, null, `expected help text for alias "${alias}"`);
assert.equal(
aliasHelp,
canonicalHelp,
`expected "${alias} --help" to be byte-identical to "${canonical} --help"`,
);
}
});

test('every CLI alias resolves to a command with help text', () => {
// Derive the alias list from the registry itself (via the canonical
// commands it targets) rather than hard-coding the five current alias
// names — a hard-coded list would silently stop covering a future sixth
// alias, reintroducing exactly the drift this test exists to catch.
const aliases = listCliCommandNames().flatMap((command) =>
cliAliasesForCommand(command).map((entry) => entry.alias),
);
assert.ok(aliases.length > 0, 'expected at least one alias to exercise this test');
for (const alias of aliases) {
const help = buildCommandUsageText(normalizeCliCommandAlias(alias));
assert.notEqual(help, null, `expected buildCommandUsageText to resolve alias "${alias}"`);
}
});

test('rotate still has no fast-path help', () => {
// `rotate` is not in the alias registry, so it must fall through to the
// slow path (`src/cli/parser/args.ts`'s `normalizeCommandAlias`), which is
// where the "renamed to orientation" migration error is raised. The fast
// path must never special-case `rotate` itself.
const help = buildCommandUsageText(normalizeCliCommandAlias('rotate'));
assert.equal(help, null);
});
47 changes: 47 additions & 0 deletions src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot';
import { handleInteractionCommands } from '../interaction.ts';
import { handleSnapshotCommands } from '../snapshot.ts';
import { dispatchCommand } from '../../../core/dispatch.ts';
Expand Down Expand Up @@ -234,6 +235,52 @@ test('a changed capture with a different presentation keeps the tap failure', as
expect(sessionStore.get(sessionName)?.actions).toHaveLength(0);
});

test('corroborates a tap when the request carries no flags and the baseline used a non-default scope', async () => {
const sessionName = 'ios-no-flags-tap-corroboration';
const sessionStore = makeSessionStore();
const baseline = snapshot(profileNodes);
baseline.presentationKey = buildSnapshotPresentationKey({ depth: 2, raw: false });
sessionStore.set(
sessionName,
makeIosSession(sessionName, {
appBundleId: 'com.example.app',
snapshot: baseline,
}),
);
mockDispatch.mockImplementation(async (_device, command) => {
if (command === 'press') {
throw new AppError(
'XCTEST_RECORDED_FAILURE',
'XCTest recorded a failure while executing tap; the action may not have been performed.',
);
}
if (command === 'snapshot') return snapshotPayload(imageViewerNodes);
return {};
});

// Deliberately built without a `flags` key at all (not `flags: {}`) — this
// mirrors the real production paths (batch steps with no flags, JSON-RPC
// requests that omit the key) that hide the bug this test pins.
const response = await handleInteractionCommands({
req: {
token: 'test',
session: sessionName,
command: 'click',
positionals: ['id="unfollow"'],
},
sessionName,
sessionStore,
contextFromFlags,
});

expect(response?.ok).toBe(true);
if (response?.ok) {
expect(response.data?.warning).toMatch(/post-action accessibility capture changed/);
expect(response.data?.selector).toBe('id="unfollow"');
}
expect(sessionStore.get(sessionName)?.actions).toHaveLength(1);
});

test('a changed capture against a stale baseline keeps the tap failure', async () => {
const sessionName = 'ios-stale-baseline-tap-corroboration';
const sessionStore = makeSessionStore();
Expand Down
Loading
Loading