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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect
- Match existing style. Remove imports/variables your change made unused.
- Test through public interfaces when possible. Do not add unrelated exports just to make tests easier.
- Prefer type-level checks when TypeScript can enforce a contract or invalid shape.
- Use `unknown` only at trust boundaries: parsed JSON, daemon/runtime payloads, catch values, generic I/O, or parser callbacks. Once a value is validated or its producer has a known contract, narrow to a domain type or focused parser/helper instead of carrying `unknown` through internal helper and formatter signatures.
- Keep modules small for agent context safety:
- target <= 300 LOC per implementation file when practical.
- if a file grows past 500 LOC, plan/extract focused submodules before adding new behavior.
Expand Down
12 changes: 7 additions & 5 deletions scripts/write-xcuitest-cache-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,9 @@ function collectConfiguredTestTargets(parsed) {
if (!Array.isArray(testConfigurations)) return [];
const targets = [];
for (const config of testConfigurations) {
if (!isRecord(config) || !Array.isArray(config.TestTargets)) continue;
if (!isRecord(config) || !Array.isArray(config.TestTargets)) {
continue;
}
targets.push(...config.TestTargets.filter(isRecord));
}
return targets;
Expand All @@ -373,6 +375,10 @@ function collectLegacyTestTargets(parsed) {
return Object.values(parsed).filter((value) => isRecord(value) && 'TestBundlePath' in value);
}

function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}

function collectXctestrunProductReferenceValuesFromTarget(target) {
const values = new Set();
const productReferenceKeys = new Set([
Expand Down Expand Up @@ -452,7 +458,3 @@ function readFileSize(filePath) {
return null;
}
}

function isRecord(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
88 changes: 88 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,47 @@ test('apps.open forwards explicit runtime hints through the daemon request', asy
});
});

test('client close normalizes target shutdown results', async () => {
const setup = createTransport(async () => ({
ok: true,
data:
setup.calls.length === 1
? {
shutdown: {
success: false,
exitCode: -1,
stdout: '',
stderr: 'simctl shutdown failed',
error: {
code: 'COMMAND_FAILED',
message: 'simctl shutdown failed',
details: { retryable: false },
},
},
}
: {
shutdown: { success: true },
},
}));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

const sessionClose = await client.sessions.close({ shutdown: true });
const appClose = await client.apps.close({ shutdown: true });

assert.deepEqual(sessionClose.shutdown, {
success: false,
exitCode: -1,
stdout: '',
stderr: 'simctl shutdown failed',
error: {
code: 'COMMAND_FAILED',
message: 'simctl shutdown failed',
details: { retryable: false },
},
});
assert.equal(appClose.shutdown, undefined);
});

test('observability.perf projects structured frame area to daemon positionals', async () => {
const setup = createTransport(async (req) => {
if (req.command === 'perf') {
Expand Down Expand Up @@ -701,6 +742,53 @@ test('client capture.snapshot forwards force-full as snapshotForceFull flag', as
assert.equal(setup.calls[0]?.flags?.snapshotForceFull, true);
});

test('client capture.screenshot normalizes overlay refs from daemon response data', async () => {
const setup = createTransport(async () => ({
ok: true,
data: {
path: '/tmp/screenshot.png',
overlayRefs: [
{
ref: '@e1',
label: 'Continue',
rect: { x: 10, y: 20, width: 30, height: 40 },
overlayRect: { x: 12, y: 22, width: 34, height: 44 },
center: { x: 25, y: 40 },
},
{
ref: '@missing-center',
rect: { x: 1, y: 2, width: 3, height: 4 },
overlayRect: { x: 1, y: 2, width: 3, height: 4 },
},
{
ref: '@array-rect',
rect: [],
overlayRect: { x: 1, y: 2, width: 3, height: 4 },
center: { x: 2, y: 3 },
},
'not-an-overlay-ref',
],
},
}));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

const result = await client.capture.screenshot({ overlayRefs: true });

assert.deepEqual(result, {
path: '/tmp/screenshot.png',
overlayRefs: [
{
ref: '@e1',
label: 'Continue',
rect: { x: 10, y: 20, width: 30, height: 40 },
overlayRect: { x: 12, y: 22, width: 34, height: 44 },
center: { x: 25, y: 40 },
},
],
identifiers: { session: 'qa' },
});
});

test('sessions.stateDir resolves locally without contacting the daemon', async () => {
const setup = createTransport(async () => {
throw new Error('unexpected daemon call');
Expand Down
14 changes: 3 additions & 11 deletions src/backend.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import type { AlertAction, AlertInfo } from './alert-contract.ts';
import type { AppsFilter } from './contracts/app-inventory.ts';
import type {
Point,
ScreenshotOverlayRef,
SnapshotNode,
SnapshotOptions,
SnapshotState,
} from './utils/snapshot.ts';
import type { Point, SnapshotNode, SnapshotOptions, SnapshotState } from './utils/snapshot.ts';
import type { NetworkIncludeMode } from './contracts.ts';
import type { DeviceTarget, Platform, PlatformSelector } from './utils/device.ts';
import type { BackMode } from './core/back-mode.ts';
Expand All @@ -22,6 +16,7 @@ import type {
SnapshotCaptureAnnotations,
SnapshotCaptureFreshness,
} from './snapshot-capture-annotations.ts';
import type { ScreenshotResultData } from './utils/screenshot-result.ts';

export type AgentDeviceBackendPlatform = Platform;

Expand Down Expand Up @@ -78,10 +73,7 @@ export type BackendScreenshotOptions = {
surface?: SessionSurface;
};

export type BackendScreenshotResult = {
path?: string;
overlayRefs?: ScreenshotOverlayRef[];
};
export type BackendScreenshotResult = ScreenshotResultData;

export type BackendActionResult = Record<string, unknown> | void;

Expand Down
2 changes: 2 additions & 0 deletions src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type {
BatchFlags,
BatchInvoke,
BatchRequest,
BatchRunResponse,
BatchRunResult,
DaemonBatchStep,
BatchStepResult,
NormalizedBatchStep,
Expand Down
59 changes: 39 additions & 20 deletions src/cli/batch-steps.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { BatchStep } from '../client-types.ts';
import { daemonRuntimeSchema, type SessionRuntimeHints } from '../contracts.ts';
import { readInputFromCli } from '../commands/cli-grammar.ts';
import { isCommandName, type CommandName } from '../commands/command-metadata.ts';
import type { CliFlags } from '../utils/cli-flags.ts';
import { AppError } from '../utils/errors.ts';
import { isRecord } from '../utils/parsing.ts';

type LegacyCliBatchStep = {
command: CommandName;
positionals?: string[];
flags?: Record<string, unknown>;
runtime?: unknown;
runtime?: SessionRuntimeHints;
};

export function readCliBatchStepsJson(raw: string): BatchStep[] {
Expand All @@ -27,7 +29,7 @@ export function readCliBatchStepsJson(raw: string): BatchStep[] {
function normalizeCliBatchSteps(steps: unknown[]): BatchStep[] {
let sawLegacyStep = false;
const normalized = steps.map((step, index) => {
if (isStructuredBatchStep(step)) return step;
if (isStructuredBatchStepShape(step)) return readStructuredBatchStep(step, index + 1);
const legacyStep = readLegacyCliBatchStep(step, index + 1);
sawLegacyStep = true;
return legacyStepToStructuredStep(legacyStep);
Expand All @@ -53,31 +55,36 @@ function legacyStepToStructuredStep(legacyStep: LegacyCliBatchStep): BatchStep {
};
}

function isStructuredBatchStep(step: unknown): step is BatchStep {
return (
step !== null &&
typeof step === 'object' &&
!Array.isArray(step) &&
'input' in step &&
!('positionals' in step) &&
!('flags' in step)
);
function isStructuredBatchStepShape(step: unknown): step is Record<string, unknown> & BatchStep {
return isRecord(step) && 'input' in step && !('positionals' in step) && !('flags' in step);
}

function readStructuredBatchStep(
step: Record<string, unknown> & BatchStep,
stepNumber: number,
): BatchStep {
const runtime = readRuntimeHints(step.runtime, stepNumber);
const { runtime: _runtime, ...rest } = step;
return {
...rest,
...(runtime === undefined ? {} : { runtime }),
};
}

function readLegacyCliBatchStep(step: unknown, stepNumber: number): LegacyCliBatchStep {
if (!step || typeof step !== 'object' || Array.isArray(step)) {
if (!isRecord(step)) {
throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`);
}
const record = step as Record<string, unknown>;
assertLegacyBatchStepKeys(record, stepNumber);
const command = readLegacyCommand(record.command, stepNumber);
const positionals = readLegacyPositionals(record.positionals, stepNumber);
const flags = readLegacyFlags(record.flags, stepNumber);
assertLegacyBatchStepKeys(step, stepNumber);
const command = readLegacyCommand(step.command, stepNumber);
const positionals = readLegacyPositionals(step.positionals, stepNumber);
const flags = readLegacyFlags(step.flags, stepNumber);
const runtime = readRuntimeHints(step.runtime, stepNumber);
return {
command,
...(positionals === undefined ? {} : { positionals }),
...(flags === undefined ? {} : { flags }),
...(record.runtime === undefined ? {} : { runtime: record.runtime }),
...(runtime === undefined ? {} : { runtime }),
};
}

Expand Down Expand Up @@ -116,10 +123,22 @@ function readLegacyPositionals(value: unknown, stepNumber: number): string[] | u

function readLegacyFlags(value: unknown, stepNumber: number): Record<string, unknown> | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
if (!isRecord(value)) {
throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} flags must be an object.`);
}
return value as Record<string, unknown>;
return value;
}

function readRuntimeHints(value: unknown, stepNumber: number): SessionRuntimeHints | undefined {
if (value === undefined) return undefined;
try {
return daemonRuntimeSchema.parse(value);
} catch (error) {
throw new AppError(
'INVALID_ARGS',
`Batch step ${stepNumber} runtime is invalid: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

function cliFlagsFromBatchStep(flags: Record<string, unknown> | undefined): CliFlags {
Expand Down
64 changes: 36 additions & 28 deletions src/client-normalizers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { CommandFlags } from './core/dispatch.ts';
import { screenshotFlagsFromOptions } from './contracts/screenshot.ts';
import type { DaemonRequest, SessionRuntimeHints } from './daemon/types.ts';
import { AppError } from './utils/errors.ts';
import type { ScreenshotOverlayRef, SnapshotNode } from './utils/snapshot.ts';
import { AppError, type NormalizedError } from './utils/errors.ts';
import type { SnapshotNode } from './utils/snapshot.ts';
import { buildAppIdentifiers, buildDeviceIdentifiers } from './client-shared.ts';
import type {
AgentDeviceDevice,
Expand All @@ -13,15 +13,14 @@ import type {
InternalRequestOptions,
MaterializationReleaseResult,
StartupPerfSample,
TargetShutdownResult,
} from './client-types.ts';
import {
asRecord,
isRecord,
readDeviceTarget,
readNullableString,
readOptionalString,
readPoint,
readRect,
readRequiredDeviceKind,
readRequiredNumber,
readRequiredPlatform,
Expand Down Expand Up @@ -228,35 +227,44 @@ export function normalizeStartupSample(value: unknown): StartupPerfSample | unde
};
}

export function normalizeTargetShutdownResult(value: unknown): TargetShutdownResult | undefined {
if (!isRecord(value)) return undefined;
if (
typeof value.success !== 'boolean' ||
typeof value.exitCode !== 'number' ||
typeof value.stdout !== 'string' ||
typeof value.stderr !== 'string'
) {
return undefined;
}
const error = normalizeTargetShutdownError(value.error);
return {
success: value.success,
exitCode: value.exitCode,
stdout: value.stdout,
stderr: value.stderr,
...(error ? { error } : {}),
};
}

function normalizeTargetShutdownError(value: unknown): NormalizedError | undefined {
if (!isRecord(value)) return undefined;
if (typeof value.code !== 'string' || typeof value.message !== 'string') return undefined;
return {
code: value.code,
message: value.message,
...(typeof value.hint === 'string' ? { hint: value.hint } : {}),
...(typeof value.diagnosticId === 'string' ? { diagnosticId: value.diagnosticId } : {}),
...(typeof value.logPath === 'string' ? { logPath: value.logPath } : {}),
...(isRecord(value.details) ? { details: value.details } : {}),
};
}

export function readSnapshotNodes(value: unknown): SnapshotNode[] {
// Snapshot nodes are produced by the daemon snapshot pipeline and treated as trusted here.
return Array.isArray(value) ? (value as SnapshotNode[]) : [];
}

export function readScreenshotOverlayRefs(
record: Record<string, unknown>,
): ScreenshotOverlayRef[] | undefined {
const value = record.overlayRefs;
if (!Array.isArray(value)) return undefined;
const refs: ScreenshotOverlayRef[] = [];
for (const entry of value) {
if (!isRecord(entry)) continue;
const ref = readOptionalString(entry, 'ref');
const rect = readRect(entry, 'rect');
const overlayRect = readRect(entry, 'overlayRect');
const center = readPoint(entry, 'center');
if (!ref || !rect || !overlayRect || !center) continue;
refs.push({
ref,
label: readOptionalString(entry, 'label'),
rect,
overlayRect,
center,
});
}
return refs;
}

export function buildFlags(options: InternalRequestOptions): CommandFlags {
return stripUndefined({
stateDir: options.stateDir,
Expand Down
Loading
Loading