Skip to content
Merged
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
23 changes: 23 additions & 0 deletions src/agent-device-client-metro-hints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { afterEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import * as daemonResolution from '@agent-device/daemon-contracts/daemon-resolution';
import { createAgentDeviceClient } from './agent-device-client.ts';
import { createTransport } from './__tests__/client-transport-fixture.ts';

afterEach(() => vi.restoreAllMocks());

for (const command of ['open', 'close'] as const) {
test(`${command} succeeds when optional Metro hint scope resolution fails`, async () => {
const setup = createTransport(async () => ({ ok: true, data: {} }));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
vi.spyOn(daemonResolution, 'resolveDaemonPaths').mockImplementation(() => {
throw new Error('home directory unavailable');
});
const result =
command === 'open'
? await client.apps.open({ app: 'com.example.app', platform: 'ios' })
: await client.sessions.close();
assert.equal(result.session, setup.config.session);
assert.equal(setup.calls[0]?.command, command);
});
}
130 changes: 12 additions & 118 deletions src/agent-device-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,7 @@ import type {
} from '@agent-device/contracts/client';
import type { AgentArtifactsResult } from '@agent-device/contracts/observability';
import type { MetroPrepareOptions } from '@agent-device/contracts/remote';
import {
isNonDefaultResponseLevel,
type ResponseLevel,
type SessionRuntimeHints,
} from '@agent-device/kernel/contracts';
import { isNonDefaultResponseLevel, type ResponseLevel } from '@agent-device/kernel/contracts';
import { AppError, throwDaemonError } from '@agent-device/kernel/errors';
import { createRequestId } from '@agent-device/host-kit/diagnostics';
import {
Expand All @@ -54,7 +50,7 @@ import {
readSnapshotNodes,
resolveSessionName,
} from './client/client-normalizers.ts';
import type { AgentDeviceClient, MetroPrepareResult } from './client/client-types.ts';
import type { AgentDeviceClient } from './client/client-types.ts';
import { INTERNAL_COMMANDS } from '@agent-device/command-registry/catalog';
import { buildRequestFlags } from './commands/command-flags.ts';
import {
Expand All @@ -64,13 +60,12 @@ import {
import type { CommandResult } from '@agent-device/command-registry/command-result';
import { sendToDaemon } from './daemon-client/daemon-client.ts';
import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution';
import { resolveMetroServerUrl } from './metro/metro-reload-endpoints.ts';
import { prepareMetroRuntime, reloadMetro } from './metro/client-metro.ts';
import {
clearMetroSessionHints,
readMetroSessionHints,
writeMetroSessionHints,
type MetroSessionHints,
clearMetroSessionHintsQuietly,
recordPreparedMetroSession,
recordOpenedMetroSession,
readMetroSessionRuntime,
} from './metro/metro-session-hints.ts';
import {
asOptionalRecord,
Expand Down Expand Up @@ -226,7 +221,7 @@ export function createAgentDeviceClient(
};
} finally {
// Close is teardown intent: drop the dev-server binding even if the daemon call fails.
clearMetroSessionHintsQuietly(config, options);
clearMetroSessionHintsQuietly(mergeClientOptions(config, options));
}
},
saveScript: async (
Expand Down Expand Up @@ -281,9 +276,8 @@ export function createAgentDeviceClient(
open: async (options: AppOpenOptions) => {
const session = resolveRequestSession(options);
const data = await executeCommand<Record<string, unknown>>('open', options);
recordMetroSessionHintsAfterOpen({
config,
options,
recordOpenedMetroSession(mergeClientOptions(config, options), {
explicitRuntime: options.runtime !== undefined,
runtime: mergeClientOptions(config, options).runtime,
sessionReused: data.sessionReused === true,
});
Expand Down Expand Up @@ -367,16 +361,13 @@ export function createAgentDeviceClient(
runtimeFilePath: options.runtimeFilePath,
logPath: options.logPath,
});
persistMetroSessionHints(config, result);
recordPreparedMetroSession(config, result);
return result;
},
reload: async (options = {}) =>
await reloadMetro({
metroHost: options.metroHost,
metroPort: options.metroPort,
bundleUrl: options.bundleUrl,
runtime: resolveMetroSessionHints(config) ?? config.runtime,
timeoutMs: options.timeoutMs,
...options,
runtime: readMetroSessionRuntime(config) ?? config.runtime,
}),
},
capture: {
Expand Down Expand Up @@ -586,101 +577,4 @@ function mergeClientOptions(
return { ...config, ...options };
}

function metroSessionHintsScope(
config: AgentDeviceClientConfig,
options: InternalRequestOptions = {},
): { stateDir: string; session: string } {
const merged = mergeClientOptions(config, options);
return {
stateDir: resolveDaemonPaths(merged.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR).baseDir,
session: resolveSessionName(merged.session),
};
}

// The metro-sessions file is the session's dev-server binding; see MetroSessionHints.
function persistMetroSessionHints(
config: AgentDeviceClientConfig,
result: Pick<MetroPrepareResult, 'statusUrl' | 'iosRuntime' | 'androidRuntime'>,
): void {
try {
const deviceBaseUrls = new Set<string>();
for (const runtime of [result.iosRuntime, result.androidRuntime]) {
try {
const hints = metroHintsFromRuntime(runtime);
if (hints) deviceBaseUrls.add(hints.controlBaseUrl);
} catch {}
}
writeMetroSessionHints({
...metroSessionHintsScope(config),
hints: {
controlBaseUrl: new URL('.', result.statusUrl).toString(),
deviceBaseUrls: [...deviceBaseUrls],
},
});
} catch {
// Session-hint persistence is best-effort; reload still works with explicit flags.
}
}

function resolveMetroSessionHints(
config: AgentDeviceClientConfig,
): SessionRuntimeHints | undefined {
try {
const binding = readMetroSessionHints(metroSessionHintsScope(config));
return binding ? { bundleUrl: binding.controlBaseUrl } : undefined;
} catch {
return undefined;
}
}

function metroHintsFromRuntime(
runtime: SessionRuntimeHints | undefined,
): MetroSessionHints | undefined {
const { metroHost, metroPort, bundleUrl } = runtime ?? {};
if (metroHost === undefined && metroPort === undefined && bundleUrl === undefined)
return undefined;
return { controlBaseUrl: resolveMetroServerUrl({ runtime }).toString() };
}

// Hint flags rebind the session's dev server; a hintless open that created the session clears
// any leftover binding from a previous same-name session.
function recordMetroSessionHintsAfterOpen(params: {
config: AgentDeviceClientConfig;
options: InternalRequestOptions;
runtime: SessionRuntimeHints | undefined;
sessionReused: boolean;
}): void {
try {
const scope = metroSessionHintsScope(params.config, params.options);
const hints = metroHintsFromRuntime(params.runtime);
if (hints) {
const binding =
params.options.runtime === undefined ? readMetroSessionHints(scope) : undefined;
if (
binding &&
(params.sessionReused || binding.deviceBaseUrls?.includes(hints.controlBaseUrl))
)
return;
writeMetroSessionHints({ ...scope, hints });
return;
}
if (!params.sessionReused) {
clearMetroSessionHints(scope);
}
} catch {
// Session-hint sync is best-effort; reload still works with explicit flags.
}
}

function clearMetroSessionHintsQuietly(
config: AgentDeviceClientConfig,
options: InternalRequestOptions,
): void {
try {
clearMetroSessionHints(metroSessionHintsScope(config, options));
} catch {
// Session-hint cleanup is best-effort; close must not fail on local file state.
}
}

export type * from './client/client-types.ts';
14 changes: 1 addition & 13 deletions src/cli/commands/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ import { runCliCommandWithOutput } from '../../commands/cli-runner.ts';
import { isParseableOutputCommand } from '../../commands/cli-output.ts';
import { routeResponseWarnings } from '../../commands/output-common.ts';
import type { CommandName } from '../../commands/command-metadata.ts';
import type { CliOutput } from '../../commands/command-contract.ts';
import type { ReplaySuiteResult } from '@agent-device/contracts/replay';
import type { CliFlags } from '@agent-device/contracts/command';
import { readCommandMessage } from '@agent-device/kernel/success-text';
import { isNonDefaultResponseLevel } from '@agent-device/kernel/contracts';
import { writeCommandOutput } from './shared.ts';
import { writeCommandOutput, writeCliOutput } from './shared.ts';
import type { ClientBackedCliCommandName } from './client-backed.ts';
import type { ClientCommandParams } from './router-types.ts';

Expand Down Expand Up @@ -82,14 +81,3 @@ async function writeGenericCliOutput(
);
return 0;
}

async function writeCliOutput(flags: CliFlags, output: CliOutput): Promise<void> {
if (!flags.json && output.stderr) {
process.stderr.write(output.stderr);
}
await writeCommandOutput(
flags,
flags.json ? (output.jsonData ?? output.data) : output.data,
() => output.text,
);
}
21 changes: 15 additions & 6 deletions src/cli/commands/react-devtools.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { CliFlags } from '@agent-device/contracts/command';
import { writeCliOutput } from './shared.ts';
import { AppError } from '@agent-device/kernel/errors';
import type { ReactDevtoolsCommandOptions } from '../react-devtools/react-devtools-connection.ts';
import {
Expand All @@ -8,12 +10,19 @@ import { runReactProfileCommand } from '../react-devtools/react-profile-command.

export async function runReactDevtoolsCommand(
args: string[],
options: ReactDevtoolsCommandOptions,
options: ReactDevtoolsCommandOptions & { flags?: Partial<Pick<CliFlags, 'json'>> },
): Promise<number> {
const [command, ...rest] = args;
if (command === 'profile') return await runReactProfileCommand(rest, options);
if (isInspectCommand(command)) return await runReactInspectCommand(command, rest, options);
throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, {
hint: 'Use react-devtools tree, find, inspect, errors, or profile.',
});
const output =
command === 'profile'
? await runReactProfileCommand(rest, options)
: isInspectCommand(command)
? await runReactInspectCommand(command, rest, options)
: undefined;
if (!output)
throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, {
hint: 'Use react-devtools tree, find, inspect, errors, or profile.',
});
await writeCliOutput(options.flags ?? {}, output);
return 0;
}
15 changes: 14 additions & 1 deletion src/cli/commands/shared.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { CliOutput } from '../../commands/command-contract.ts';
import type { CliFlags } from '@agent-device/contracts/command';

export async function writeCommandOutput(
flags: CliFlags,
flags: Partial<Pick<CliFlags, 'json'>>,
data: unknown,
renderHuman?: () => string | null | undefined,
): Promise<void> {
Expand All @@ -17,3 +18,15 @@ export async function writeCommandOutput(
function writeLine(text: string): void {
process.stdout.write(text.endsWith('\n') ? text : `${text}\n`);
}

export async function writeCliOutput(
flags: Partial<Pick<CliFlags, 'json'>>,
output: CliOutput,
): Promise<void> {
if (!flags.json && output.stderr) process.stderr.write(output.stderr);
await writeCommandOutput(
flags,
flags.json ? (output.jsonData ?? output.data) : output.data,
() => output.text,
);
}
2 changes: 1 addition & 1 deletion src/cli/react-devtools/react-component-inspect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { componentName, componentRef, kindLabel } from './react-component-tree.ts';
import { componentName, componentRef, kindLabel } from './react-component.ts';
import { printable, printableLines } from './react-devtools-text.ts';

type ValuePath = Array<string | number>;
Expand Down
27 changes: 1 addition & 26 deletions src/cli/react-devtools/react-component-tree.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { componentName, componentRef, kindLabel } from './react-component.ts';
import { AppError } from '@agent-device/kernel/errors';
import { printable } from './react-devtools-text.ts';

Expand Down Expand Up @@ -36,21 +37,6 @@ export type ComponentTree = {
logsTracked: boolean;
};

const ELEMENT_CLASS = 1;
const ELEMENT_FORWARD_REF = 6;
const ELEMENT_MEMO = 8;

const TYPE_WRAPPER = /^(?:Memo|ForwardRef)\((.+)\)$/;

/** `Memo(Row)` reads as `Row`: the element type already says it is memoized. */
export function componentName(displayName: string): string {
let name = displayName;
for (let match = TYPE_WRAPPER.exec(name); match; match = TYPE_WRAPPER.exec(name)) {
name = match[1]!;
}
return name;
}

export function buildComponentTree(raw: RawComponentTree): ComponentTree {
const byId = new Map<number, TreeNode>();
for (const [id, displayName, elementType, , ownerId, key] of raw.nodes) {
Expand Down Expand Up @@ -89,17 +75,6 @@ export function buildComponentTree(raw: RawComponentTree): ComponentTree {
return { roots, byId, logsTracked: raw.logsTracked };
}

export function componentRef(node: { id: number }): string {
return `@c${node.id}`;
}

export function kindLabel(elementType: number): string {
if (elementType === ELEMENT_MEMO) return ' [memo]';
if (elementType === ELEMENT_CLASS) return ' [class]';
if (elementType === ELEMENT_FORWARD_REF) return ' [forwardRef]';
return '';
}

function plural(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
Expand Down
22 changes: 22 additions & 0 deletions src/cli/react-devtools/react-component.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { componentKind, componentName, splitDisplayName } from './react-component.ts';

test('splitDisplayName separates DevTools wrapper names from the component', () => {
assert.deepEqual(splitDisplayName('Memo(ForwardRef(Row))'), {
name: 'Row',
wrappers: ['Memo', 'ForwardRef'],
});
assert.deepEqual(splitDisplayName('Row'), { name: 'Row', wrappers: [] });
});

test('tree names preserve non-React wrappers', () => {
assert.equal(componentName('Memo(Animated(Row))'), 'Animated(Row)');
assert.equal(componentName('ForwardRef(Memo(Row))'), 'Row');
});

for (const type of ['toString', 'constructor', '__proto__']) {
test(`inherited key ${type} is not a component kind`, () => {
assert.equal(componentKind(JSON.parse(JSON.stringify(type))), 'unknown');
});
}
Loading
Loading