From f25917907e50b072f0a237efb2a8de5de2523324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 13:28:06 +0200 Subject: [PATCH 01/13] refactor: tighten daemon screenshot typing --- src/__tests__/client.test.ts | 47 +++++++++++++++++++ src/client-normalizers.ts | 28 +---------- src/client.ts | 5 +- src/commands/output-common.ts | 8 ---- src/commands/perf/output.ts | 12 ++--- src/daemon/screenshot-runtime.ts | 18 ++----- .../ios/runner-xctestrun-products.ts | 12 ++--- src/utils/screenshot-overlay-refs.ts | 40 ++++++++++++++++ 8 files changed, 105 insertions(+), 65 deletions(-) create mode 100644 src/utils/screenshot-overlay-refs.ts diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index ec7be2cd9..7361d8fbf 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -701,6 +701,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'); diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 0424edcb0..dfb096554 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -2,7 +2,7 @@ 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 type { SnapshotNode } from './utils/snapshot.ts'; import { buildAppIdentifiers, buildDeviceIdentifiers } from './client-shared.ts'; import type { AgentDeviceDevice, @@ -20,8 +20,6 @@ import { readDeviceTarget, readNullableString, readOptionalString, - readPoint, - readRect, readRequiredDeviceKind, readRequiredNumber, readRequiredPlatform, @@ -233,30 +231,6 @@ export function readSnapshotNodes(value: unknown): SnapshotNode[] { return Array.isArray(value) ? (value as SnapshotNode[]) : []; } -export function readScreenshotOverlayRefs( - record: Record, -): 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, diff --git a/src/client.ts b/src/client.ts index efca6d455..bbb0a3858 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,7 +16,6 @@ import { normalizeInstallFromSourceResult, normalizeMaterializationReleaseResult, normalizeOpenDevice, - readScreenshotOverlayRefs, normalizeRuntimeHints, normalizeSession, normalizeStartupSample, @@ -25,6 +24,7 @@ import { readSnapshotNodes, resolveSessionName, } from './client-normalizers.ts'; +import { readScreenshotResultData } from './utils/screenshot-overlay-refs.ts'; import type { AgentDeviceClient, AgentDeviceClientConfig, @@ -276,9 +276,10 @@ export function createAgentDeviceClient( screenshot: async (options: CaptureScreenshotOptions = {}) => { const session = resolveRequestSession(options); const data = await executeCommand>('screenshot', options); + const screenshot = readScreenshotResultData(data); return { path: readRequiredString(data, 'path'), - overlayRefs: readScreenshotOverlayRefs(data), + overlayRefs: screenshot?.overlayRefs, identifiers: { session }, }; }, diff --git a/src/commands/output-common.ts b/src/commands/output-common.ts index 42ff2e337..ad3892826 100644 --- a/src/commands/output-common.ts +++ b/src/commands/output-common.ts @@ -23,11 +23,3 @@ export function readRecord(value: unknown): Record | undefined ? (value as Record) : undefined; } - -export function readRecordArray(value: unknown): Array> { - return Array.isArray(value) ? value.filter(isRecord) : []; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} diff --git a/src/commands/perf/output.ts b/src/commands/perf/output.ts index bb944da16..689e477e0 100644 --- a/src/commands/perf/output.ts +++ b/src/commands/perf/output.ts @@ -1,11 +1,6 @@ import type { CommandRequestResult } from '../../client-types.ts'; import type { CliOutput } from '../command-contract.ts'; -import { - readRecord, - readRecordArray, - resultOutput, - type CliOutputFormatter, -} from '../output-common.ts'; +import { readRecord, resultOutput, type CliOutputFormatter } from '../output-common.ts'; function perfCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; @@ -177,7 +172,10 @@ function formatSampleWindow(sampleWindowMs: number | undefined): string { } function formatWorstFrameWindows(fps: Record): string[] { - return readRecordArray(fps.worstWindows).flatMap((window) => { + if (!Array.isArray(fps.worstWindows)) return []; + return fps.worstWindows.flatMap((entry) => { + const window = readRecord(entry); + if (!window) return []; const line = formatWorstFrameWindow(window); return line ? [line] : []; }); diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index ca5520330..24bcde06f 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -1,12 +1,13 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { AgentDeviceBackend, BackendScreenshotResult } from '../backend.ts'; +import type { AgentDeviceBackend } from '../backend.ts'; import type { ArtifactAdapter } from '../io.ts'; import { createAgentDevice, localCommandPolicy } from '../runtime.ts'; import { dispatchCommand } from '../core/dispatch.ts'; import { screenshotFlagsFromOptions, screenshotOptionsFromFlags } from '../contracts/screenshot.ts'; import { AppError } from '../utils/errors.ts'; +import { readScreenshotResultData } from '../utils/screenshot-overlay-refs.ts'; import type { DaemonCommandContext } from './context.ts'; import type { SessionState } from './types.ts'; import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; @@ -58,28 +59,17 @@ function createDispatchScreenshotBackend(params: { surface: options?.surface, }; if (outputPlacement === 'out') { - return toBackendScreenshotResult( + return readScreenshotResultData( await dispatchCommand(session.device, 'screenshot', [], outPath, context), ); } - return toBackendScreenshotResult( + return readScreenshotResultData( await dispatchCommand(session.device, 'screenshot', [outPath], undefined, context), ); }, }; } -function toBackendScreenshotResult(data: unknown): BackendScreenshotResult | void { - if (typeof data !== 'object' || data === null) return; - const record = data as Record; - return { - ...(typeof record.path === 'string' ? { path: record.path } : {}), - ...(Array.isArray(record.overlayRefs) - ? { overlayRefs: record.overlayRefs as NonNullable } - : {}), - }; -} - function createDaemonScreenshotArtifactAdapter(): ArtifactAdapter { return { resolveInput: async () => { diff --git a/src/platforms/ios/runner-xctestrun-products.ts b/src/platforms/ios/runner-xctestrun-products.ts index 737bc0440..eccb03f1f 100644 --- a/src/platforms/ios/runner-xctestrun-products.ts +++ b/src/platforms/ios/runner-xctestrun-products.ts @@ -108,7 +108,11 @@ async function resolveXctestrunProductReferences(xctestrunPath: string): Promise function resolveXctestrunProductReferencesFromJson(parsed: Record): string[] { const values = new Set(); - for (const target of collectXctestrunProductReferenceTargets(parsed)) { + for (const target of [ + parsed, + ...collectConfiguredTestTargets(parsed), + ...collectLegacyTestTargets(parsed), + ]) { for (const value of collectXctestrunProductReferenceValuesFromTarget(target)) { values.add(value); } @@ -117,12 +121,6 @@ function resolveXctestrunProductReferencesFromJson(parsed: Record, -): Record[] { - return [parsed, ...collectConfiguredTestTargets(parsed), ...collectLegacyTestTargets(parsed)]; -} - function collectConfiguredTestTargets(parsed: Record): Record[] { const testConfigurations = parsed.TestConfigurations; if (!Array.isArray(testConfigurations)) { diff --git a/src/utils/screenshot-overlay-refs.ts b/src/utils/screenshot-overlay-refs.ts new file mode 100644 index 000000000..8b39a659e --- /dev/null +++ b/src/utils/screenshot-overlay-refs.ts @@ -0,0 +1,40 @@ +import type { ScreenshotOverlayRef } from './snapshot.ts'; +import { readPoint, readRect } from './parsing.ts'; + +export type ScreenshotResultData = { + path?: string; + overlayRefs?: ScreenshotOverlayRef[]; +}; + +export function readScreenshotResultData(value: unknown): ScreenshotResultData | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const path = typeof record.path === 'string' ? record.path : undefined; + const overlayRefs = Array.isArray(record.overlayRefs) + ? record.overlayRefs.flatMap((entry) => { + const overlayRef = readScreenshotOverlayRef(entry); + return overlayRef ? [overlayRef] : []; + }) + : undefined; + return { + ...(path ? { path } : {}), + ...(overlayRefs && overlayRefs.length > 0 ? { overlayRefs } : {}), + }; +} + +function readScreenshotOverlayRef(value: unknown): ScreenshotOverlayRef | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const ref = typeof record.ref === 'string' && record.ref.length > 0 ? record.ref : undefined; + const rect = readRect(record.rect); + const overlayRect = readRect(record.overlayRect); + const center = readPoint(record.center); + if (!ref || !rect || !overlayRect || !center) return undefined; + return { + ref, + ...(typeof record.label === 'string' && record.label.length > 0 ? { label: record.label } : {}), + rect, + overlayRect, + center, + }; +} From 8412738c3be10b93be7b9cbef1425b8935b0dcdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 13:35:35 +0200 Subject: [PATCH 02/13] refactor: share screenshot result typing --- src/backend.ts | 14 +++----------- src/client-types.ts | 11 +++-------- src/client.ts | 2 +- src/daemon/screenshot-runtime.ts | 2 +- ...enshot-overlay-refs.ts => screenshot-result.ts} | 8 ++++---- 5 files changed, 12 insertions(+), 25 deletions(-) rename src/utils/{screenshot-overlay-refs.ts => screenshot-result.ts} (86%) diff --git a/src/backend.ts b/src/backend.ts index 1902dcb9b..193f65da8 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -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'; @@ -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; @@ -78,10 +73,7 @@ export type BackendScreenshotOptions = { surface?: SessionSurface; }; -export type BackendScreenshotResult = { - path?: string; - overlayRefs?: ScreenshotOverlayRef[]; -}; +export type BackendScreenshotResult = ScreenshotResultData; export type BackendActionResult = Record | void; diff --git a/src/client-types.ts b/src/client-types.ts index 4885e47f6..8f415169a 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -28,12 +28,8 @@ import type { ScrollInputDirection } from './commands/interaction/runtime/gestur import type { LogAction } from './contracts/logs.ts'; import type { SessionSurface } from './core/session-surface.ts'; import type { FindLocator } from './utils/finders.ts'; -import type { - ScreenshotOverlayRef, - SnapshotNode, - SnapshotUnchanged, - SnapshotVisibility, -} from './utils/snapshot.ts'; +import type { SnapshotNode, SnapshotUnchanged, SnapshotVisibility } from './utils/snapshot.ts'; +import type { ScreenshotResultData } from './utils/screenshot-result.ts'; import type { MetroPrepareKind, PrepareMetroRuntimeResult, @@ -357,9 +353,8 @@ export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & { surface?: SessionSurface; }; -export type CaptureScreenshotResult = { +export type CaptureScreenshotResult = ScreenshotResultData & { path: string; - overlayRefs?: ScreenshotOverlayRef[]; identifiers: AgentDeviceIdentifiers; }; diff --git a/src/client.ts b/src/client.ts index bbb0a3858..40754b3c6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -24,7 +24,7 @@ import { readSnapshotNodes, resolveSessionName, } from './client-normalizers.ts'; -import { readScreenshotResultData } from './utils/screenshot-overlay-refs.ts'; +import { readScreenshotResultData } from './utils/screenshot-result.ts'; import type { AgentDeviceClient, AgentDeviceClientConfig, diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index 24bcde06f..4cb950e09 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -7,7 +7,7 @@ import { createAgentDevice, localCommandPolicy } from '../runtime.ts'; import { dispatchCommand } from '../core/dispatch.ts'; import { screenshotFlagsFromOptions, screenshotOptionsFromFlags } from '../contracts/screenshot.ts'; import { AppError } from '../utils/errors.ts'; -import { readScreenshotResultData } from '../utils/screenshot-overlay-refs.ts'; +import { readScreenshotResultData } from '../utils/screenshot-result.ts'; import type { DaemonCommandContext } from './context.ts'; import type { SessionState } from './types.ts'; import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; diff --git a/src/utils/screenshot-overlay-refs.ts b/src/utils/screenshot-result.ts similarity index 86% rename from src/utils/screenshot-overlay-refs.ts rename to src/utils/screenshot-result.ts index 8b39a659e..dad052e0a 100644 --- a/src/utils/screenshot-overlay-refs.ts +++ b/src/utils/screenshot-result.ts @@ -18,7 +18,7 @@ export function readScreenshotResultData(value: unknown): ScreenshotResultData | : undefined; return { ...(path ? { path } : {}), - ...(overlayRefs && overlayRefs.length > 0 ? { overlayRefs } : {}), + ...(overlayRefs ? { overlayRefs } : {}), }; } @@ -26,9 +26,9 @@ function readScreenshotOverlayRef(value: unknown): ScreenshotOverlayRef | undefi if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; const record = value as Record; const ref = typeof record.ref === 'string' && record.ref.length > 0 ? record.ref : undefined; - const rect = readRect(record.rect); - const overlayRect = readRect(record.overlayRect); - const center = readPoint(record.center); + const rect = readRect(record, 'rect'); + const overlayRect = readRect(record, 'overlayRect'); + const center = readPoint(record, 'center'); if (!ref || !rect || !overlayRect || !center) return undefined; return { ref, From 81d4f1d7205de738e8a98e1d9794a30f7f622e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:21:13 +0200 Subject: [PATCH 03/13] refactor: inline record shape checks --- scripts/write-xcuitest-cache-metadata.mjs | 29 +++++++++++----- src/client-normalizers.ts | 33 ++++++++++--------- .../ios/debug-symbols/crash-artifact.ts | 6 ++-- src/platforms/ios/debug-symbols/utils.ts | 12 +++---- .../ios/runner-xctestrun-products.ts | 22 ++++++++----- src/utils/parsing.ts | 26 +++++++-------- 6 files changed, 74 insertions(+), 54 deletions(-) diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs index e09e11f17..1b44db197 100644 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ b/scripts/write-xcuitest-cache-metadata.mjs @@ -362,15 +362,32 @@ function collectConfiguredTestTargets(parsed) { if (!Array.isArray(testConfigurations)) return []; const targets = []; for (const config of testConfigurations) { - if (!isRecord(config) || !Array.isArray(config.TestTargets)) continue; - targets.push(...config.TestTargets.filter(isRecord)); + if ( + config === null || + typeof config !== 'object' || + Array.isArray(config) || + !Array.isArray(config.TestTargets) + ) { + continue; + } + targets.push( + ...config.TestTargets.filter( + (target) => target !== null && typeof target === 'object' && !Array.isArray(target), + ), + ); } return targets; } function collectLegacyTestTargets(parsed) { - if (!isRecord(parsed)) return []; - return Object.values(parsed).filter((value) => isRecord(value) && 'TestBundlePath' in value); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return []; + return Object.values(parsed).filter( + (value) => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + 'TestBundlePath' in value, + ); } function collectXctestrunProductReferenceValuesFromTarget(target) { @@ -452,7 +469,3 @@ function readFileSize(filePath) { return null; } } - -function isRecord(value) { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index dfb096554..91a67fd87 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -16,7 +16,6 @@ import type { } from './client-types.ts'; import { asRecord, - isRecord, readDeviceTarget, readNullableString, readOptionalString, @@ -156,12 +155,13 @@ function buildClientDevicePlatformFields( } export function normalizeRuntimeHints(value: unknown): SessionRuntimeHints | undefined { - if (!isRecord(value)) return undefined; - const platform = value.platform; - const metroHost = readOptionalString(value, 'metroHost'); - const metroPort = typeof value.metroPort === 'number' ? value.metroPort : undefined; - const bundleUrl = readOptionalString(value, 'bundleUrl'); - const launchUrl = readOptionalString(value, 'launchUrl'); + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const platform = record.platform; + const metroHost = readOptionalString(record, 'metroHost'); + const metroPort = typeof record.metroPort === 'number' ? record.metroPort : undefined; + const bundleUrl = readOptionalString(record, 'bundleUrl'); + const launchUrl = readOptionalString(record, 'launchUrl'); return { platform: platform === 'ios' || platform === 'android' ? platform : undefined, metroHost, @@ -209,20 +209,21 @@ export function normalizeOpenDevice( } export function normalizeStartupSample(value: unknown): StartupPerfSample | undefined { - if (!isRecord(value)) return undefined; + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; if ( - typeof value.durationMs !== 'number' || - typeof value.measuredAt !== 'string' || - typeof value.method !== 'string' + typeof record.durationMs !== 'number' || + typeof record.measuredAt !== 'string' || + typeof record.method !== 'string' ) { return undefined; } return { - durationMs: value.durationMs, - measuredAt: value.measuredAt, - method: value.method, - appTarget: readOptionalString(value, 'appTarget'), - appBundleId: readOptionalString(value, 'appBundleId'), + durationMs: record.durationMs, + measuredAt: record.measuredAt, + method: record.method, + appTarget: readOptionalString(record, 'appTarget'), + appBundleId: readOptionalString(record, 'appBundleId'), }; } diff --git a/src/platforms/ios/debug-symbols/crash-artifact.ts b/src/platforms/ios/debug-symbols/crash-artifact.ts index 7b4b6f2f5..91b364d35 100644 --- a/src/platforms/ios/debug-symbols/crash-artifact.ts +++ b/src/platforms/ios/debug-symbols/crash-artifact.ts @@ -9,7 +9,6 @@ import type { } from './types.ts'; import { addressKey, - isRecord, normalizeUuid, parseAtosSymbol, readJsonRecord, @@ -68,7 +67,10 @@ function readIpsFrameRecords(thread: unknown): Record[] { if (!thread || typeof thread !== 'object') return []; const frames = (thread as Record).frames; return Array.isArray(frames) - ? frames.filter((frame): frame is Record => isRecord(frame)) + ? frames.filter( + (frame): frame is Record => + frame !== null && typeof frame === 'object' && !Array.isArray(frame), + ) : []; } diff --git a/src/platforms/ios/debug-symbols/utils.ts b/src/platforms/ios/debug-symbols/utils.ts index c6d927422..1a6bbc5b5 100644 --- a/src/platforms/ios/debug-symbols/utils.ts +++ b/src/platforms/ios/debug-symbols/utils.ts @@ -32,18 +32,18 @@ export function compactJoin(values: (string | undefined)[]): string | undefined return compact.length > 0 ? compact.join(' ') : undefined; } -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - export function readRecord(value: unknown): Record | undefined { - return isRecord(value) ? value : undefined; + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; } export function readJsonRecord(text: string): Record | null { try { const value = JSON.parse(text); - return isRecord(value) ? value : null; + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; } catch { return null; } diff --git a/src/platforms/ios/runner-xctestrun-products.ts b/src/platforms/ios/runner-xctestrun-products.ts index eccb03f1f..2289e5f69 100644 --- a/src/platforms/ios/runner-xctestrun-products.ts +++ b/src/platforms/ios/runner-xctestrun-products.ts @@ -129,12 +129,18 @@ function collectConfiguredTestTargets(parsed: Record): Record[] = []; for (const config of testConfigurations) { - if (!isRecord(config)) { + if (config === null || typeof config !== 'object' || Array.isArray(config)) { continue; } - const testTargets = config.TestTargets; + const configRecord = config as Record; + const testTargets = configRecord.TestTargets; if (Array.isArray(testTargets)) { - targets.push(...testTargets.filter(isRecord)); + targets.push( + ...testTargets.filter( + (target): target is Record => + target !== null && typeof target === 'object' && !Array.isArray(target), + ), + ); } } return targets; @@ -142,14 +148,14 @@ function collectConfiguredTestTargets(parsed: Record): Record): Record[] { return Object.values(parsed).filter( - (value): value is Record => isRecord(value) && 'TestBundlePath' in value, + (value): value is Record => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + 'TestBundlePath' in value, ); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object'; -} - function collectXctestrunProductReferenceValuesFromTarget( target: Record, ): string[] { diff --git a/src/utils/parsing.ts b/src/utils/parsing.ts index b22863f4d..4c55c82bd 100644 --- a/src/utils/parsing.ts +++ b/src/utils/parsing.ts @@ -73,11 +73,12 @@ export function readDeviceTarget(record: Record, key: string): export function readRect(record: Record, key: string): Rect | undefined { const value = record[key]; - if (!isRecord(value)) return undefined; - const x = readNumberField(value, 'x'); - const y = readNumberField(value, 'y'); - const width = readNumberField(value, 'width'); - const height = readNumberField(value, 'height'); + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const valueRecord = value as Record; + const x = readNumberField(valueRecord, 'x'); + const y = readNumberField(valueRecord, 'y'); + const width = readNumberField(valueRecord, 'width'); + const height = readNumberField(valueRecord, 'height'); if (x === undefined || y === undefined || width === undefined || height === undefined) { return undefined; } @@ -86,9 +87,10 @@ export function readRect(record: Record, key: string): Rect | u export function readPoint(record: Record, key: string): Point | undefined { const value = record[key]; - if (!isRecord(value)) return undefined; - const x = readNumberField(value, 'x'); - const y = readNumberField(value, 'y'); + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const valueRecord = value as Record; + const x = readNumberField(valueRecord, 'x'); + const y = readNumberField(valueRecord, 'y'); if (x === undefined || y === undefined) { return undefined; } @@ -127,16 +129,12 @@ function parseDeviceTarget(value: unknown): DeviceTarget | undefined { } export function asRecord(value: unknown): Record { - if (!isRecord(value)) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new AppError('COMMAND_FAILED', 'Daemon returned an unexpected response shape.', { value, }); } - return value; -} - -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return value as Record; } export function stripUndefined>(value: T): T { From e29bd60608a24092c85c5c99dd314c42c4e53f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:25:32 +0200 Subject: [PATCH 04/13] refactor: inline record readers --- src/commands/batch/output.ts | 20 ++++++--- src/commands/observability/output.ts | 12 ++++-- src/commands/output-common.ts | 6 --- src/commands/perf/output.ts | 50 +++++++++++++++++------ src/platforms/ios/debug-symbols/report.ts | 45 ++++++++++++++------ src/platforms/ios/debug-symbols/utils.ts | 6 --- src/snapshot-diagnostics.ts | 18 ++++---- 7 files changed, 103 insertions(+), 54 deletions(-) diff --git a/src/commands/batch/output.ts b/src/commands/batch/output.ts index f8e762b4f..e3cb945ac 100644 --- a/src/commands/batch/output.ts +++ b/src/commands/batch/output.ts @@ -1,7 +1,7 @@ import type { CommandRequestResult } from '../../client-types.ts'; import { readCommandMessage } from '../../utils/success-text.ts'; import type { CliOutput } from '../command-contract.ts'; -import { readRecord, resultOutput, type CliOutputFormatter } from '../output-common.ts'; +import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; function batchCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; @@ -24,8 +24,8 @@ export const batchCliOutputFormatters = { } as const satisfies Record; function renderBatchStepLine(entry: unknown): string | undefined { - const result = readRecord(entry); - if (!result) return undefined; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined; + const result = entry as Record; const step = typeof result.step === 'number' ? result.step : undefined; const command = typeof result.command === 'string' ? result.command : 'step'; const stepOk = result.ok !== false; @@ -41,8 +41,18 @@ function readBatchStepDescription( stepOk: boolean, command: string, ): string { - if (stepOk) return readCommandMessage(readRecord(result.data)) ?? command; - return readBatchStepFailure(readRecord(result.error)) ?? command; + if (stepOk) { + const data = + result.data !== null && typeof result.data === 'object' && !Array.isArray(result.data) + ? (result.data as Record) + : undefined; + return readCommandMessage(data) ?? command; + } + const error = + result.error !== null && typeof result.error === 'object' && !Array.isArray(result.error) + ? (result.error as Record) + : undefined; + return readBatchStepFailure(error) ?? command; } function readBatchStepFailure(error: Record | undefined): string | null { diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index 430f0bb8e..5b5b3640a 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -1,6 +1,6 @@ import type { CommandRequestResult } from '../../client-types.ts'; import type { CliOutput } from '../command-contract.ts'; -import { readRecord, resultOutput, type CliOutputFormatter } from '../output-common.ts'; +import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; function logsCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; @@ -67,7 +67,10 @@ function formatActionField(key: string, value: unknown): string { } function formatNetworkEntry(entry: unknown): string[] { - const record = readRecord(entry) ?? {}; + const record = + entry !== null && typeof entry === 'object' && !Array.isArray(entry) + ? (entry as Record) + : {}; const method = typeof record.method === 'string' ? record.method : 'HTTP'; const url = typeof record.url === 'string' ? record.url : ''; const status = typeof record.status === 'number' ? ` status=${record.status}` : ''; @@ -87,7 +90,10 @@ function formatNetworkEntry(entry: unknown): string[] { } function appendNetworkEntryHeaders(lines: string[], label: string, value: unknown): void { - const headers = readRecord(value); + const headers = + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; if (!headers || Object.keys(headers).length === 0) return; lines.push(` ${label}: ${JSON.stringify(headers)}`); } diff --git a/src/commands/output-common.ts b/src/commands/output-common.ts index ad3892826..e8dbe6c28 100644 --- a/src/commands/output-common.ts +++ b/src/commands/output-common.ts @@ -17,9 +17,3 @@ export const messageOutput = resultOutput(messageCliOutput); export function messageCliOutput(result: Record): CliOutput { return { data: result, text: readCommandMessage(result) }; } - -export function readRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} diff --git a/src/commands/perf/output.ts b/src/commands/perf/output.ts index 689e477e0..52537c08a 100644 --- a/src/commands/perf/output.ts +++ b/src/commands/perf/output.ts @@ -1,6 +1,6 @@ import type { CommandRequestResult } from '../../client-types.ts'; import type { CliOutput } from '../command-contract.ts'; -import { readRecord, resultOutput, type CliOutputFormatter } from '../output-common.ts'; +import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; function perfCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; @@ -14,12 +14,21 @@ export const perfCliOutputFormatters = { function formatPerfCliOutput(data: Record): string { const nativeOutput = formatNativePerfOutput(data); if (nativeOutput) return nativeOutput; - const artifact = readRecord(data.artifact); + const artifact = + data.artifact !== null && typeof data.artifact === 'object' && !Array.isArray(data.artifact) + ? (data.artifact as Record) + : undefined; if (artifact) { return formatMemoryArtifactSummary(artifact); } - const metrics = readRecord(data.metrics); - const fps = readRecord(metrics?.fps); + const metrics = + data.metrics !== null && typeof data.metrics === 'object' && !Array.isArray(data.metrics) + ? (data.metrics as Record) + : undefined; + const fps = + metrics?.fps !== null && typeof metrics?.fps === 'object' && !Array.isArray(metrics.fps) + ? (metrics.fps as Record) + : undefined; const resourceSummary = buildResourcePerfSummary(metrics); if (!fps) { return formatPerfUnavailable(resourceSummary, 'missing frame metric'); @@ -120,8 +129,16 @@ function readString(value: unknown): string | undefined { } function formatNativePerfFrameHealth(data: Record): string { - const summary = readRecord(data.summary); - const frameHealth = readRecord(summary?.frameHealth); + const summary = + data.summary !== null && typeof data.summary === 'object' && !Array.isArray(data.summary) + ? (data.summary as Record) + : undefined; + const frameHealth = + summary?.frameHealth !== null && + typeof summary?.frameHealth === 'object' && + !Array.isArray(summary.frameHealth) + ? (summary.frameHealth as Record) + : undefined; if (!frameHealth || frameHealth.available !== true) return ''; const droppedFramePercent = readFiniteNumber(frameHealth.droppedFramePercent); const droppedFrameCount = readFiniteNumber(frameHealth.droppedFrameCount); @@ -174,8 +191,8 @@ function formatSampleWindow(sampleWindowMs: number | undefined): string { function formatWorstFrameWindows(fps: Record): string[] { if (!Array.isArray(fps.worstWindows)) return []; return fps.worstWindows.flatMap((entry) => { - const window = readRecord(entry); - if (!window) return []; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return []; + const window = entry as Record; const line = formatWorstFrameWindow(window); return line ? [line] : []; }); @@ -197,10 +214,19 @@ function formatWorstFrameWindow(window: Record): string | undef function buildResourcePerfSummary( metrics: Record | undefined, ): string | undefined { - const parts = [ - formatCpuPerfSummary(readRecord(metrics?.cpu)), - formatMemoryPerfSummary(readRecord(metrics?.memory)), - ].filter((part): part is string => Boolean(part)); + const cpu = + metrics?.cpu !== null && typeof metrics?.cpu === 'object' && !Array.isArray(metrics.cpu) + ? (metrics.cpu as Record) + : undefined; + const memory = + metrics?.memory !== null && + typeof metrics?.memory === 'object' && + !Array.isArray(metrics.memory) + ? (metrics.memory as Record) + : undefined; + const parts = [formatCpuPerfSummary(cpu), formatMemoryPerfSummary(memory)].filter( + (part): part is string => Boolean(part), + ); return parts.length > 0 ? parts.join(', ') : undefined; } diff --git a/src/platforms/ios/debug-symbols/report.ts b/src/platforms/ios/debug-symbols/report.ts index 1827552e9..e90e4c66d 100644 --- a/src/platforms/ios/debug-symbols/report.ts +++ b/src/platforms/ios/debug-symbols/report.ts @@ -14,7 +14,6 @@ import { hex, readNumber, readJsonRecord, - readRecord, readString, } from './utils.ts'; @@ -74,17 +73,26 @@ function readIpsBundleId( payload: Record, header: Record | null, ): string | undefined { - return firstString(readRecord(payload.bundleInfo)?.CFBundleIdentifier, header?.bundleID); + const bundleInfo = + payload.bundleInfo !== null && + typeof payload.bundleInfo === 'object' && + !Array.isArray(payload.bundleInfo) + ? (payload.bundleInfo as Record) + : undefined; + return firstString(bundleInfo?.CFBundleIdentifier, header?.bundleID); } function readIpsVersion( payload: Record, header: Record | null, ): string | undefined { - return firstString( - readRecord(payload.bundleInfo)?.CFBundleShortVersionString, - header?.app_version, - ); + const bundleInfo = + payload.bundleInfo !== null && + typeof payload.bundleInfo === 'object' && + !Array.isArray(payload.bundleInfo) + ? (payload.bundleInfo as Record) + : undefined; + return firstString(bundleInfo?.CFBundleShortVersionString, header?.app_version); } function readIpsIncident( @@ -102,7 +110,10 @@ function readIpsTimestamp( } function readIpsExceptionType(exception: unknown): string | undefined { - return readString(readRecord(exception)?.type); + if (exception === null || typeof exception !== 'object' || Array.isArray(exception)) { + return undefined; + } + return readString((exception as Record).type); } function readIpsHeader(header: string | undefined): Record | null { @@ -113,19 +124,29 @@ function readIpsCrashedThread(payload: Record): number | undefi const faultingThread = readNumber(payload.faultingThread); if (faultingThread !== undefined) return faultingThread; const threads = Array.isArray(payload.threads) ? payload.threads : []; - const triggeredIndex = threads.findIndex((thread) => readRecord(thread)?.triggered === true); + const triggeredIndex = threads.findIndex( + (thread) => + thread !== null && + typeof thread === 'object' && + !Array.isArray(thread) && + (thread as Record).triggered === true, + ); return triggeredIndex === -1 ? undefined : triggeredIndex; } function readIpsExceptionCodes(exception: unknown): string | undefined { - const record = readRecord(exception); - if (!record) return undefined; + if (exception === null || typeof exception !== 'object' || Array.isArray(exception)) { + return undefined; + } + const record = exception as Record; return firstString(record.codes, record.rawCodes); } function readIpsTerminationReason(termination: unknown): string | undefined { - const record = readRecord(termination); - if (!record) return undefined; + if (termination === null || typeof termination !== 'object' || Array.isArray(termination)) { + return undefined; + } + const record = termination as Record; return compactJoin([ readString(record.namespace), readString(record.code), diff --git a/src/platforms/ios/debug-symbols/utils.ts b/src/platforms/ios/debug-symbols/utils.ts index 1a6bbc5b5..5e0f55032 100644 --- a/src/platforms/ios/debug-symbols/utils.ts +++ b/src/platforms/ios/debug-symbols/utils.ts @@ -32,12 +32,6 @@ export function compactJoin(values: (string | undefined)[]): string | undefined return compact.length > 0 ? compact.join(' ') : undefined; } -export function readRecord(value: unknown): Record | undefined { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} - export function readJsonRecord(text: string): Record | null { try { const value = JSON.parse(text); diff --git a/src/snapshot-diagnostics.ts b/src/snapshot-diagnostics.ts index 5dad88c78..fba4a37c4 100644 --- a/src/snapshot-diagnostics.ts +++ b/src/snapshot-diagnostics.ts @@ -128,8 +128,8 @@ function formatSlowSnapshotWarning(stats: SnapshotTimingStats): string { } function readSnapshotTimingStats(value: unknown): SnapshotTimingStats | undefined { - const record = readRecord(value); - if (!record) return undefined; + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; const required = readRequiredSnapshotTimingStats(record); if (!required) return undefined; return { @@ -138,12 +138,6 @@ function readSnapshotTimingStats(value: unknown): SnapshotTimingStats | undefine }; } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readRequiredSnapshotTimingStats( record: Record, ): @@ -174,8 +168,12 @@ function readOptionalSnapshotTimingStats( record: Record, ): Pick { const platform = typeof record.platform === 'string' ? record.platform : undefined; - const backendRecord = readRecord(record.backends); - const backends = backendRecord ? readBackendCounts(backendRecord) : undefined; + const backends = + record.backends !== null && + typeof record.backends === 'object' && + !Array.isArray(record.backends) + ? readBackendCounts(record.backends as Record) + : undefined; return { ...(platform ? { platform: platform as SnapshotTimingStats['platform'] } : {}), ...(backends ? { backends } : {}), From d3e0a16d6a93061e7de42dc82daef453b989aa27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:40:16 +0200 Subject: [PATCH 05/13] refactor: type batch and network output --- src/batch.ts | 1 + src/client-types.ts | 5 +- src/commands/batch/cli.test.ts | 36 +------------- src/commands/batch/output.ts | 57 ++++------------------ src/commands/observability/output.ts | 72 +++++++++++++++------------- src/core/batch.ts | 20 +++++--- src/index.ts | 1 + 7 files changed, 69 insertions(+), 123 deletions(-) diff --git a/src/batch.ts b/src/batch.ts index a9b2ec4b7..e2b150a81 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -11,6 +11,7 @@ export type { BatchFlags, BatchInvoke, BatchRequest, + BatchRunResult, DaemonBatchStep, BatchStepResult, NormalizedBatchStep, diff --git a/src/client-types.ts b/src/client-types.ts index 8f415169a..150510e0e 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -38,8 +38,9 @@ import type { import type { MetroBridgeScope } from './client-companion-tunnel-contract.ts'; import type { AppsFilter } from './contracts/app-inventory.ts'; import type { ScreenshotRequestFlags } from './contracts/screenshot.ts'; +import type { BatchRunResult, DaemonBatchStep } from './core/batch.ts'; +export type { BatchRunResult } from './core/batch.ts'; import type { PerfAction, PerfArea, PerfKind, PerfSubject } from './contracts/perf.ts'; -import type { DaemonBatchStep } from './core/batch.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; import type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts'; @@ -996,7 +997,7 @@ export type AgentDeviceClient = { test: (options: ReplayTestOptions) => Promise; }; batch: { - run: (options: BatchRunOptions) => Promise; + run: (options: BatchRunOptions) => Promise; }; observability: { perf: (options?: PerfOptions) => Promise; diff --git a/src/commands/batch/cli.test.ts b/src/commands/batch/cli.test.ts index 5a1761285..ce55bfbef 100644 --- a/src/commands/batch/cli.test.ts +++ b/src/commands/batch/cli.test.ts @@ -13,7 +13,7 @@ import { const batchDefaultResponse: DaemonResponse = { ok: true, - data: { total: 1, executed: 1, totalDurationMs: 1 }, + data: { total: 0, executed: 0, totalDurationMs: 1, results: [] }, }; function runCliCapture( @@ -300,37 +300,3 @@ test('batch human output renders per-step results', async () => { assert.match(result.stdout, /1\. OK Opened: Settings \(7ms\)/); assert.match(result.stdout, /2\. OK Typed 5 chars \(8ms\)/); }); - -test('batch human output renders failed steps distinctly', async () => { - const result = await runCliCapture( - ['batch', '--steps', '[{"command":"open","input":{}}]'], - async () => ({ - ok: true, - data: { - total: 2, - executed: 1, - totalDurationMs: 15, - results: [ - { - step: 1, - command: 'open', - ok: true, - data: { appName: 'Settings', message: 'Opened: Settings' }, - durationMs: 7, - }, - { - step: 2, - command: 'type', - ok: false, - error: { message: 'type requires text' }, - durationMs: 8, - }, - ], - }, - }), - ); - - assert.equal(result.code, null); - assert.match(result.stdout, /1\. OK Opened: Settings \(7ms\)/); - assert.match(result.stdout, /2\. FAILED type requires text \(8ms\)/); -}); diff --git a/src/commands/batch/output.ts b/src/commands/batch/output.ts index e3cb945ac..f6b9448ae 100644 --- a/src/commands/batch/output.ts +++ b/src/commands/batch/output.ts @@ -1,60 +1,23 @@ -import type { CommandRequestResult } from '../../client-types.ts'; +import type { BatchRunResult, BatchStepResult } from '../../core/batch.ts'; import { readCommandMessage } from '../../utils/success-text.ts'; import type { CliOutput } from '../command-contract.ts'; import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; -function batchCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const total = typeof data.total === 'number' ? data.total : 0; - const executed = typeof data.executed === 'number' ? data.executed : 0; - const durationMs = typeof data.totalDurationMs === 'number' ? data.totalDurationMs : undefined; +function batchCliOutput(result: BatchRunResult): CliOutput { const lines = [ - `Batch completed: ${executed}/${total} steps${durationMs !== undefined ? ` in ${durationMs}ms` : ''}`, + `Batch completed: ${result.executed}/${result.total} steps in ${result.totalDurationMs}ms`, ]; - const results = Array.isArray(data.results) ? data.results : []; - for (const entry of results) { - const line = renderBatchStepLine(entry); - if (line) lines.push(line); + for (const entry of result.results) { + lines.push(renderBatchStepLine(entry)); } - return { data, text: lines.join('\n') }; + return { data: result, text: lines.join('\n') }; } export const batchCliOutputFormatters = { - batch: resultOutput(batchCliOutput), + batch: resultOutput(batchCliOutput), } as const satisfies Record; -function renderBatchStepLine(entry: unknown): string | undefined { - if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined; - const result = entry as Record; - const step = typeof result.step === 'number' ? result.step : undefined; - const command = typeof result.command === 'string' ? result.command : 'step'; - const stepOk = result.ok !== false; - const description = readBatchStepDescription(result, stepOk, command); - const prefix = step !== undefined ? `${step}. ` : '- '; - const durationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; - const durationSuffix = durationMs !== undefined ? ` (${durationMs}ms)` : ''; - return `${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}`; -} - -function readBatchStepDescription( - result: Record, - stepOk: boolean, - command: string, -): string { - if (stepOk) { - const data = - result.data !== null && typeof result.data === 'object' && !Array.isArray(result.data) - ? (result.data as Record) - : undefined; - return readCommandMessage(data) ?? command; - } - const error = - result.error !== null && typeof result.error === 'object' && !Array.isArray(result.error) - ? (result.error as Record) - : undefined; - return readBatchStepFailure(error) ?? command; -} - -function readBatchStepFailure(error: Record | undefined): string | null { - return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; +function renderBatchStepLine(result: BatchStepResult): string { + const description = readCommandMessage(result.data) ?? result.command; + return `${result.step}. OK ${description} (${result.durationMs}ms)`; } diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index 5b5b3640a..30b3fcc89 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -1,7 +1,21 @@ import type { CommandRequestResult } from '../../client-types.ts'; +import type { BackendNetworkEntry } from '../../backend.ts'; +import type { NetworkEntry } from '../../daemon/network-log.ts'; import type { CliOutput } from '../command-contract.ts'; import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; +type NetworkCliEntry = (BackendNetworkEntry | NetworkEntry) & { + headers?: string; + requestHeaders?: Record; + responseHeaders?: Record; +}; + +type NetworkCliResult = Record & { + path?: string; + entries: readonly NetworkCliEntry[]; + notes?: readonly string[]; +}; + function logsCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const pathOut = typeof data.path === 'string' ? data.path : ''; @@ -17,16 +31,13 @@ function logsCliOutput(result: CommandRequestResult): CliOutput { }; } -function networkCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; +function networkCliOutput(data: NetworkCliResult): CliOutput { const lines: string[] = []; - const pathOut = typeof data.path === 'string' ? data.path : ''; - if (pathOut) lines.push(pathOut); - const entries = Array.isArray(data.entries) ? data.entries : []; - if (entries.length === 0) { + if (data.path) lines.push(data.path); + if (data.entries.length === 0) { lines.push('No recent HTTP(s) entries found.'); } else { - for (const entry of entries) { + for (const entry of data.entries) { lines.push(...formatNetworkEntry(entry)); } } @@ -49,7 +60,7 @@ function networkCliOutput(result: CommandRequestResult): CliOutput { export const observabilityCliOutputFormatters = { logs: resultOutput(logsCliOutput), - network: resultOutput(networkCliOutput), + network: resultOutput(networkCliOutput), } as const satisfies Record; function formatActionFields(data: Record): string | undefined { @@ -66,40 +77,35 @@ function formatActionField(key: string, value: unknown): string { return typeof value === 'number' ? `${key}=${value}` : ''; } -function formatNetworkEntry(entry: unknown): string[] { - const record = - entry !== null && typeof entry === 'object' && !Array.isArray(entry) - ? (entry as Record) - : {}; - const method = typeof record.method === 'string' ? record.method : 'HTTP'; - const url = typeof record.url === 'string' ? record.url : ''; - const status = typeof record.status === 'number' ? ` status=${record.status}` : ''; - const timestamp = typeof record.timestamp === 'string' ? `${record.timestamp} ` : ''; - const durationMs = - typeof record.durationMs === 'number' ? ` durationMs=${record.durationMs}` : ''; +function formatNetworkEntry(entry: NetworkCliEntry): string[] { + const method = entry.method ?? 'HTTP'; + const url = entry.url ?? ''; + const status = entry.status !== undefined ? ` status=${entry.status}` : ''; + const timestamp = entry.timestamp ? `${entry.timestamp} ` : ''; + const durationMs = entry.durationMs !== undefined ? ` durationMs=${entry.durationMs}` : ''; const lines = [`${timestamp}${method} ${url}${status}${durationMs}`]; - const hasFormattedHeaders = typeof record.headers === 'string'; - appendNetworkEntryBody(lines, 'headers', record.headers); - if (!hasFormattedHeaders) { - appendNetworkEntryHeaders(lines, 'request headers', record.requestHeaders); - appendNetworkEntryHeaders(lines, 'response headers', record.responseHeaders); + if ('headers' in entry && entry.headers) { + appendNetworkEntryBody(lines, 'headers', entry.headers); + } else { + appendNetworkEntryHeaders(lines, 'request headers', entry.requestHeaders); + appendNetworkEntryHeaders(lines, 'response headers', entry.responseHeaders); } - appendNetworkEntryBody(lines, 'request', record.requestBody); - appendNetworkEntryBody(lines, 'response', record.responseBody); + appendNetworkEntryBody(lines, 'request', entry.requestBody); + appendNetworkEntryBody(lines, 'response', entry.responseBody); return lines; } -function appendNetworkEntryHeaders(lines: string[], label: string, value: unknown): void { - const headers = - value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; +function appendNetworkEntryHeaders( + lines: string[], + label: string, + headers: Record | undefined, +): void { if (!headers || Object.keys(headers).length === 0) return; lines.push(` ${label}: ${JSON.stringify(headers)}`); } -function appendNetworkEntryBody(lines: string[], label: string, value: unknown): void { - if (typeof value === 'string') lines.push(` ${label}: ${value}`); +function appendNetworkEntryBody(lines: string[], label: string, value: string | undefined): void { + if (value !== undefined) lines.push(` ${label}: ${value}`); } function formatKeyValueFields(data: Record, fields: string[]): string | undefined { diff --git a/src/core/batch.ts b/src/core/batch.ts index 0cb8ea7d3..1f393359a 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -48,6 +48,13 @@ export type BatchStepResult = { durationMs: number; }; +export type BatchRunResult = Record & { + total: number; + executed: number; + totalDurationMs: number; + results: BatchStepResult[]; +}; + export async function runBatch( req: BatchRequest, sessionName: string, @@ -94,14 +101,15 @@ export async function runBatch( } partialResults.push(stepResponse.result); } + const data: BatchRunResult = { + total: steps.length, + executed: steps.length, + totalDurationMs: Date.now() - startedAt, + results: partialResults, + }; return { ok: true, - data: { - total: steps.length, - executed: steps.length, - totalDurationMs: Date.now() - startedAt, - results: partialResults, - }, + data, }; } catch (error) { const appErr = asAppError(error); diff --git a/src/index.ts b/src/index.ts index 907950b94..937266080 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ export type { AppTriggerEventOptions, BackCommandOptions, BackCommandResult, + BatchRunResult, BatchRunOptions, BatchStep, CaptureDiffOptions, From 3a003126bc1d387bdcd22b06734c1bfc3d5b0123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:00:05 +0200 Subject: [PATCH 06/13] refactor: type batch run response --- src/batch.ts | 1 + src/commands/batch/public.test.ts | 6 ++---- src/core/batch.ts | 9 ++++++++- .../provider-scenarios/android-lifecycle.test.ts | 5 +---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/batch.ts b/src/batch.ts index e2b150a81..016a0b03e 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -11,6 +11,7 @@ export type { BatchFlags, BatchInvoke, BatchRequest, + BatchRunResponse, BatchRunResult, DaemonBatchStep, BatchStepResult, diff --git a/src/commands/batch/public.test.ts b/src/commands/batch/public.test.ts index 3a1f75dee..8ead6f9fe 100644 --- a/src/commands/batch/public.test.ts +++ b/src/commands/batch/public.test.ts @@ -7,7 +7,6 @@ import { buildBatchStepFlags, runBatch, validateAndNormalizeBatchSteps, - type BatchStepResult, } from '../../batch.ts'; import type { DaemonRequest } from '../../contracts.ts'; @@ -43,9 +42,8 @@ test('public batch entrypoint exports daemon-compatible orchestration helpers', assert.equal(response.ok, true); assert.deepEqual(seenCommands, ['open', 'wait']); if (response.ok) { - assert.equal(response.data?.total, 2); - const results = response.data?.results as BatchStepResult[]; - assert.equal(results[0]?.command, 'open'); + assert.equal(response.data.total, 2); + assert.equal(response.data.results[0]?.command, 'open'); } }); diff --git a/src/core/batch.ts b/src/core/batch.ts index 1f393359a..0e15a1db1 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -55,11 +55,18 @@ export type BatchRunResult = Record & { results: BatchStepResult[]; }; +export type BatchRunResponse = + | { + ok: true; + data: BatchRunResult; + } + | Extract; + export async function runBatch( req: BatchRequest, sessionName: string, invoke: BatchInvoke, -): Promise { +): Promise { const flags = readBatchFlags(req.flags); const batchOnError = flags?.batchOnError ?? 'stop'; if (batchOnError !== 'stop') { diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 025149847..881497a93 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -1104,10 +1104,7 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( ...selection, }); assert.equal(batch.executed, 1); - assert.equal( - (batch.results as Array<{ data?: { count?: number } }> | undefined)?.[0]?.data?.count, - 2, - ); + assert.equal(batch.results[0]?.data.count, 2); const replayPath = path.join(tempRoot, 'settings-search.ad'); fs.writeFileSync( From 772b96587aa9df17232d991701ab96c29daa684c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:13:50 +0200 Subject: [PATCH 07/13] refactor: narrow batch and observability output types --- AGENTS.md | 1 + src/cli/batch-steps.ts | 23 +++++- src/client-types.ts | 2 +- src/commands/batch/metadata.ts | 5 +- src/commands/batch/projection.ts | 5 +- src/commands/observability/output.ts | 73 ++++++++++++++----- src/core/batch.ts | 6 +- src/utils/cli-flags.ts | 3 +- .../android-lifecycle.test.ts | 2 +- 9 files changed, 87 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f43c8ee52..3e2de45d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src/cli/batch-steps.ts b/src/cli/batch-steps.ts index 783ea12f6..e94b42892 100644 --- a/src/cli/batch-steps.ts +++ b/src/cli/batch-steps.ts @@ -1,4 +1,5 @@ import type { BatchStep } from '../client-types.ts'; +import 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'; @@ -8,7 +9,7 @@ type LegacyCliBatchStep = { command: CommandName; positionals?: string[]; flags?: Record; - runtime?: unknown; + runtime?: SessionRuntimeHints; }; export function readCliBatchStepsJson(raw: string): BatchStep[] { @@ -60,7 +61,8 @@ function isStructuredBatchStep(step: unknown): step is BatchStep { !Array.isArray(step) && 'input' in step && !('positionals' in step) && - !('flags' in step) + !('flags' in step) && + isRuntimeHints((step as Record).runtime) ); } @@ -73,11 +75,12 @@ function readLegacyCliBatchStep(step: unknown, stepNumber: number): LegacyCliBat const command = readLegacyCommand(record.command, stepNumber); const positionals = readLegacyPositionals(record.positionals, stepNumber); const flags = readLegacyFlags(record.flags, stepNumber); + const runtime = readRuntimeHints(record.runtime, stepNumber); return { command, ...(positionals === undefined ? {} : { positionals }), ...(flags === undefined ? {} : { flags }), - ...(record.runtime === undefined ? {} : { runtime: record.runtime }), + ...(runtime === undefined ? {} : { runtime }), }; } @@ -122,6 +125,20 @@ function readLegacyFlags(value: unknown, stepNumber: number): Record; } +function readRuntimeHints(value: unknown, stepNumber: number): SessionRuntimeHints | undefined { + if (value === undefined) return undefined; + if (!isRuntimeHints(value)) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} runtime must be an object.`); + } + return value; +} + +function isRuntimeHints(value: unknown): value is SessionRuntimeHints | undefined { + return ( + value === undefined || (Boolean(value) && typeof value === 'object' && !Array.isArray(value)) + ); +} + function cliFlagsFromBatchStep(flags: Record | undefined): CliFlags { return { json: false, diff --git a/src/client-types.ts b/src/client-types.ts index 150510e0e..6ca2e05c3 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -745,7 +745,7 @@ export type ReplayTestOptions = AgentDeviceRequestOverrides & export type BatchStep = { command: string; input: Record; - runtime?: unknown; + runtime?: SessionRuntimeHints; }; export type BatchRunOptions = AgentDeviceRequestOverrides & { diff --git a/src/commands/batch/metadata.ts b/src/commands/batch/metadata.ts index 7c439c6b5..66fa4213b 100644 --- a/src/commands/batch/metadata.ts +++ b/src/commands/batch/metadata.ts @@ -1,4 +1,5 @@ import { DEFAULT_BATCH_MAX_STEPS } from '../../batch-contract.ts'; +import type { SessionRuntimeHints } from '../../contracts.ts'; import { STRUCTURED_BATCH_COMMAND_NAMES, readStructuredBatchCommandName, @@ -24,7 +25,7 @@ import { export type BatchCommandStep = { command: string; input: Record; - runtime?: unknown; + runtime?: SessionRuntimeHints; }; export type BatchInput = InferCommandInput & { @@ -170,5 +171,5 @@ function readBatchStepRuntimeProperty( ) { throw new Error(`Batch step ${stepNumber} runtime must be an object.`); } - return runtime === undefined ? {} : { runtime }; + return runtime === undefined ? {} : { runtime: runtime as SessionRuntimeHints }; } diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts index b124b7c15..e0fe7504c 100644 --- a/src/commands/batch/projection.ts +++ b/src/commands/batch/projection.ts @@ -1,4 +1,5 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { DaemonRequest } from '../../contracts.ts'; import { STRUCTURED_BATCH_COMMAND_NAMES, readStructuredBatchCommandName, @@ -87,7 +88,7 @@ function readBatchStepInput(record: Record, stepNumber: number) function readBatchStepRuntime( record: Record, stepNumber: number, -): Record | undefined { +): DaemonRequest['runtime'] { const runtime = record.runtime; if ( runtime !== undefined && @@ -95,5 +96,5 @@ function readBatchStepRuntime( ) { throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} runtime must be an object.`); } - return runtime as Record | undefined; + return runtime as DaemonRequest['runtime']; } diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index 30b3fcc89..d3c767bad 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -1,31 +1,63 @@ -import type { CommandRequestResult } from '../../client-types.ts'; import type { BackendNetworkEntry } from '../../backend.ts'; +import type { NetworkIncludeMode } from '../../contracts.ts'; import type { NetworkEntry } from '../../daemon/network-log.ts'; import type { CliOutput } from '../command-contract.ts'; import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; +type LogsActionFields = { + started?: true; + stopped?: true; + marked?: true; + cleared?: true; + restarted?: true; + removedRotatedFiles?: number; +}; + +type LogsCliResult = LogsActionFields & { + path: string; + active?: boolean; + state?: string; + backend?: string; + sizeBytes?: number; + hint?: string; + notes?: readonly string[]; +}; + +const LOG_ACTION_FIELD_KEYS = [ + 'started', + 'stopped', + 'marked', + 'cleared', + 'restarted', + 'removedRotatedFiles', +] as const satisfies readonly (keyof LogsActionFields)[]; + type NetworkCliEntry = (BackendNetworkEntry | NetworkEntry) & { headers?: string; requestHeaders?: Record; responseHeaders?: Record; }; -type NetworkCliResult = Record & { +type NetworkCliResult = { path?: string; + active?: boolean; + state?: string; + backend?: string; + include?: NetworkIncludeMode; + scannedLines?: number; + matchedLines?: number; entries: readonly NetworkCliEntry[]; notes?: readonly string[]; }; -function logsCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const pathOut = typeof data.path === 'string' ? data.path : ''; +function logsCliOutput(data: LogsCliResult): CliOutput { return { data, - text: pathOut, + text: data.path, stderr: joinDefinedLines([ - formatKeyValueFields(data, ['active', 'state', 'backend', 'sizeBytes']), + formatKeyValueFields(data, ['active', 'state', 'backend', 'sizeBytes'] as const), formatActionFields(data), - typeof data.hint === 'string' ? data.hint : undefined, + data.hint, formatNotes(data.notes), ]), }; @@ -52,27 +84,26 @@ function networkCliOutput(data: NetworkCliResult): CliOutput { 'include', 'scannedLines', 'matchedLines', - ]), + ] as const), formatNotes(data.notes), ]), }; } export const observabilityCliOutputFormatters = { - logs: resultOutput(logsCliOutput), + logs: resultOutput(logsCliOutput), network: resultOutput(networkCliOutput), } as const satisfies Record; -function formatActionFields(data: Record): string | undefined { +function formatActionFields(data: LogsActionFields): string | undefined { return ( - ['started', 'stopped', 'marked', 'cleared', 'restarted', 'removedRotatedFiles'] - .map((key) => formatActionField(key, data[key])) + LOG_ACTION_FIELD_KEYS.map((key) => formatActionField(key, data[key])) .filter(Boolean) .join(' ') || undefined ); } -function formatActionField(key: string, value: unknown): string { +function formatActionField(key: string, value: true | number | undefined): string { if (value === true) return `${key}=true`; return typeof value === 'number' ? `${key}=${value}` : ''; } @@ -84,7 +115,7 @@ function formatNetworkEntry(entry: NetworkCliEntry): string[] { const timestamp = entry.timestamp ? `${entry.timestamp} ` : ''; const durationMs = entry.durationMs !== undefined ? ` durationMs=${entry.durationMs}` : ''; const lines = [`${timestamp}${method} ${url}${status}${durationMs}`]; - if ('headers' in entry && entry.headers) { + if (entry.headers) { appendNetworkEntryBody(lines, 'headers', entry.headers); } else { appendNetworkEntryHeaders(lines, 'request headers', entry.requestHeaders); @@ -108,17 +139,19 @@ function appendNetworkEntryBody(lines: string[], label: string, value: string | if (value !== undefined) lines.push(` ${label}: ${value}`); } -function formatKeyValueFields(data: Record, fields: string[]): string | undefined { +function formatKeyValueFields>( + data: T, + fields: readonly K[], +): string | undefined { const text = fields - .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) + .map((key) => (data[key] !== undefined ? `${key}=${data[key]}` : '')) .filter(Boolean) .join(' '); return text || undefined; } -function formatNotes(notes: unknown): string | undefined { - if (!Array.isArray(notes)) return undefined; - const lines = notes.filter((note): note is string => typeof note === 'string' && note.length > 0); +function formatNotes(notes: readonly string[] | undefined): string | undefined { + const lines = notes?.filter((note) => note.length > 0) ?? []; return lines.length > 0 ? lines.join('\n') : undefined; } diff --git a/src/core/batch.ts b/src/core/batch.ts index 0e15a1db1..3d2c689e2 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -18,7 +18,7 @@ export type DaemonBatchStep = { command: string; positionals?: string[]; flags?: Record; - runtime?: unknown; + runtime?: DaemonRequest['runtime']; }; export type BatchFlags = Record & { @@ -37,7 +37,7 @@ export type NormalizedBatchStep = { command: string; positionals: string[]; flags: Record; - runtime?: unknown; + runtime?: DaemonRequest['runtime']; }; export type BatchStepResult = { @@ -248,7 +248,7 @@ async function runBatchStep( command: step.command, positionals: step.positionals, flags: stepFlags, - runtime: (step.runtime === undefined ? req.runtime : step.runtime) as DaemonRequest['runtime'], + runtime: step.runtime === undefined ? req.runtime : step.runtime, meta: req.meta, }); const durationMs = Date.now() - stepStartedAt; diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 6a1f99703..b2116edea 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -10,6 +10,7 @@ import type { DaemonTransportPreference, LeaseBackend, NetworkIncludeMode, + SessionRuntimeHints, SessionIsolationMode, } from '../contracts.ts'; import type { RemoteConfigMetroOptions } from '../remote-config-schema.ts'; @@ -129,7 +130,7 @@ export type CliFlags = RemoteConfigMetroOptions & batchSteps?: Array<{ command: string; input: Record; - runtime?: unknown; + runtime?: SessionRuntimeHints; }>; out?: string; help: boolean; diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 881497a93..08349bfab 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -1152,7 +1152,7 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( }); assert.equal(fastScreenshot.path, fastScreenshotPath); assert.ok( - Array.isArray(fastScreenshot.overlayRefs) && fastScreenshot.overlayRefs.length > 0, + fastScreenshot.overlayRefs && fastScreenshot.overlayRefs.length > 0, JSON.stringify(fastScreenshot), ); assertPngFile(fastScreenshotPath); From 650e066c6cf46ee613fd1388ac8ee009d99e4022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:23:53 +0200 Subject: [PATCH 08/13] refactor: validate typed batch runtime hints --- src/cli/batch-steps.ts | 37 +++++++++++++++++----------- src/commands/batch/cli.test.ts | 13 ++++++++++ src/commands/batch/metadata.ts | 15 +++++------ src/commands/batch/projection.ts | 16 ++++++------ src/commands/observability/output.ts | 3 +-- src/core/__tests__/batch.test.ts | 11 +++++++++ src/core/batch.ts | 22 +++++++++++------ 7 files changed, 79 insertions(+), 38 deletions(-) diff --git a/src/cli/batch-steps.ts b/src/cli/batch-steps.ts index e94b42892..3951dd4ad 100644 --- a/src/cli/batch-steps.ts +++ b/src/cli/batch-steps.ts @@ -1,5 +1,5 @@ import type { BatchStep } from '../client-types.ts'; -import type { SessionRuntimeHints } from '../contracts.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'; @@ -28,7 +28,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); @@ -54,18 +54,29 @@ function legacyStepToStructuredStep(legacyStep: LegacyCliBatchStep): BatchStep { }; } -function isStructuredBatchStep(step: unknown): step is BatchStep { +function isStructuredBatchStepShape(step: unknown): step is Record & BatchStep { return ( step !== null && typeof step === 'object' && !Array.isArray(step) && 'input' in step && !('positionals' in step) && - !('flags' in step) && - isRuntimeHints((step as Record).runtime) + !('flags' in step) ); } +function readStructuredBatchStep( + step: Record & 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)) { throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`); @@ -127,16 +138,14 @@ function readLegacyFlags(value: unknown, stepNumber: number): Record | undefined): CliFlags { diff --git a/src/commands/batch/cli.test.ts b/src/commands/batch/cli.test.ts index ce55bfbef..3b324145f 100644 --- a/src/commands/batch/cli.test.ts +++ b/src/commands/batch/cli.test.ts @@ -120,6 +120,19 @@ test('batch rejects structured replay steps before daemon dispatch', async () => assert.match(result.stderr, /not available through command batch/); }); +test('batch rejects invalid structured runtime without falling back to legacy parsing', async () => { + const result = await runCliCapture([ + 'batch', + '--steps', + '[{"command":"open","input":{"app":"settings"},"runtime":null}]', + ]); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.match(result.stderr, /Batch step 1 runtime is invalid/); + assert.doesNotMatch(result.stderr, /unknown legacy field\(s\): input/); +}); + test('batch accepts legacy positionals/flags steps with deprecation warning', async () => { const result = await runCliCapture([ 'batch', diff --git a/src/commands/batch/metadata.ts b/src/commands/batch/metadata.ts index 66fa4213b..935c77ebd 100644 --- a/src/commands/batch/metadata.ts +++ b/src/commands/batch/metadata.ts @@ -1,5 +1,5 @@ import { DEFAULT_BATCH_MAX_STEPS } from '../../batch-contract.ts'; -import type { SessionRuntimeHints } from '../../contracts.ts'; +import { daemonRuntimeSchema, type SessionRuntimeHints } from '../../contracts.ts'; import { STRUCTURED_BATCH_COMMAND_NAMES, readStructuredBatchCommandName, @@ -165,11 +165,12 @@ function readBatchStepRuntimeProperty( stepNumber: number, ): Pick { const runtime = record.runtime; - if ( - runtime !== undefined && - (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) - ) { - throw new Error(`Batch step ${stepNumber} runtime must be an object.`); + if (runtime === undefined) return {}; + try { + return { runtime: daemonRuntimeSchema.parse(runtime) }; + } catch (error) { + throw new Error( + `Batch step ${stepNumber} runtime is invalid: ${error instanceof Error ? error.message : String(error)}`, + ); } - return runtime === undefined ? {} : { runtime: runtime as SessionRuntimeHints }; } diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts index e0fe7504c..e492d0d3c 100644 --- a/src/commands/batch/projection.ts +++ b/src/commands/batch/projection.ts @@ -1,5 +1,5 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { DaemonRequest } from '../../contracts.ts'; +import { daemonRuntimeSchema, type DaemonRequest } from '../../contracts.ts'; import { STRUCTURED_BATCH_COMMAND_NAMES, readStructuredBatchCommandName, @@ -90,11 +90,13 @@ function readBatchStepRuntime( stepNumber: number, ): DaemonRequest['runtime'] { const runtime = record.runtime; - if ( - runtime !== undefined && - (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) - ) { - throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} runtime must be an object.`); + if (runtime === undefined) return undefined; + try { + return daemonRuntimeSchema.parse(runtime); + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + `Batch step ${stepNumber} runtime is invalid: ${error instanceof Error ? error.message : String(error)}`, + ); } - return runtime as DaemonRequest['runtime']; } diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index d3c767bad..15c72a464 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -104,8 +104,7 @@ function formatActionFields(data: LogsActionFields): string | undefined { } function formatActionField(key: string, value: true | number | undefined): string { - if (value === true) return `${key}=true`; - return typeof value === 'number' ? `${key}=${value}` : ''; + return value === undefined ? '' : `${key}=${value}`; } function formatNetworkEntry(entry: NetworkCliEntry): string[] { diff --git a/src/core/__tests__/batch.test.ts b/src/core/__tests__/batch.test.ts index 37dcbdaf6..d61676445 100644 --- a/src/core/__tests__/batch.test.ts +++ b/src/core/__tests__/batch.test.ts @@ -25,3 +25,14 @@ test('validateAndNormalizeBatchSteps blocks replay daemon steps', () => { /cannot run replay/i, ); }); + +test('validateAndNormalizeBatchSteps validates runtime hints', () => { + assert.throws( + () => + validateAndNormalizeBatchSteps( + [{ command: 'open', runtime: { platform: 'web' } } as unknown as DaemonBatchStep], + 10, + ), + /runtime is invalid/i, + ); +}); diff --git a/src/core/batch.ts b/src/core/batch.ts index 3d2c689e2..f58b109fe 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -1,4 +1,4 @@ -import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; +import { daemonRuntimeSchema, type DaemonRequest, type DaemonResponse } from '../contracts.ts'; import { AppError, asAppError } from '../utils/errors.ts'; import { DEFAULT_BATCH_MAX_STEPS } from '../batch-contract.ts'; import { @@ -173,22 +173,28 @@ export function validateAndNormalizeBatchSteps( ) { throw new AppError('INVALID_ARGS', `Batch step ${index + 1} flags must be an object.`); } - if ( - step.runtime !== undefined && - (typeof step.runtime !== 'object' || Array.isArray(step.runtime) || !step.runtime) - ) { - throw new AppError('INVALID_ARGS', `Batch step ${index + 1} runtime must be an object.`); - } normalized.push({ command, positionals: positionals as string[], flags: (step.flags ?? {}) as Record, - runtime: step.runtime, + runtime: readBatchStepRuntime(step.runtime, index + 1), }); } return normalized; } +function readBatchStepRuntime(value: unknown, stepNumber: number): DaemonRequest['runtime'] { + 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)}`, + ); + } +} + export function buildBatchStepFlags( parentFlags: BatchFlags | Record | undefined, stepFlags: DaemonBatchStep['flags'] | Record | undefined, From 8d6d02028ac43e685cfd4cc5fe8bd89e22d25f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:37:47 +0200 Subject: [PATCH 09/13] refactor: restore record boundary guard --- scripts/write-xcuitest-cache-metadata.mjs | 27 ++++------- src/cli/batch-steps.ts | 27 +++++------ src/client-normalizers.ts | 33 +++++++------- src/client.ts | 32 +++++-------- src/commands/batch/metadata.ts | 9 ++-- src/commands/batch/projection.ts | 7 +-- src/commands/observability/output.ts | 13 +++--- src/commands/perf/output.ts | 45 +++++-------------- src/core/batch.ts | 10 ++--- .../ios/debug-symbols/crash-artifact.ts | 24 ++++------ src/platforms/ios/debug-symbols/report.ts | 43 +++++------------- src/platforms/ios/debug-symbols/utils.ts | 5 +-- .../ios/runner-xctestrun-products.ts | 21 +++------ src/snapshot-diagnostics.ts | 22 ++++----- src/utils/parsing.ts | 26 ++++++----- src/utils/screenshot-result.ts | 24 +++++----- 16 files changed, 135 insertions(+), 233 deletions(-) diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs index 1b44db197..ea26864b9 100644 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ b/scripts/write-xcuitest-cache-metadata.mjs @@ -362,32 +362,21 @@ function collectConfiguredTestTargets(parsed) { if (!Array.isArray(testConfigurations)) return []; const targets = []; for (const config of testConfigurations) { - if ( - config === null || - typeof config !== 'object' || - Array.isArray(config) || - !Array.isArray(config.TestTargets) - ) { + if (!isRecord(config) || !Array.isArray(config.TestTargets)) { continue; } - targets.push( - ...config.TestTargets.filter( - (target) => target !== null && typeof target === 'object' && !Array.isArray(target), - ), - ); + targets.push(...config.TestTargets.filter(isRecord)); } return targets; } function collectLegacyTestTargets(parsed) { - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return []; - return Object.values(parsed).filter( - (value) => - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - 'TestBundlePath' in value, - ); + if (!isRecord(parsed)) return []; + 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) { diff --git a/src/cli/batch-steps.ts b/src/cli/batch-steps.ts index 3951dd4ad..7c5bc24ea 100644 --- a/src/cli/batch-steps.ts +++ b/src/cli/batch-steps.ts @@ -4,6 +4,7 @@ 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; @@ -55,14 +56,7 @@ function legacyStepToStructuredStep(legacyStep: LegacyCliBatchStep): BatchStep { } function isStructuredBatchStepShape(step: unknown): step is Record & BatchStep { - return ( - step !== null && - typeof step === 'object' && - !Array.isArray(step) && - 'input' in step && - !('positionals' in step) && - !('flags' in step) - ); + return isRecord(step) && 'input' in step && !('positionals' in step) && !('flags' in step); } function readStructuredBatchStep( @@ -78,15 +72,14 @@ function readStructuredBatchStep( } 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; - assertLegacyBatchStepKeys(record, stepNumber); - const command = readLegacyCommand(record.command, stepNumber); - const positionals = readLegacyPositionals(record.positionals, stepNumber); - const flags = readLegacyFlags(record.flags, stepNumber); - const runtime = readRuntimeHints(record.runtime, 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 }), @@ -130,10 +123,10 @@ function readLegacyPositionals(value: unknown, stepNumber: number): string[] | u function readLegacyFlags(value: unknown, stepNumber: number): Record | 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; + return value; } function readRuntimeHints(value: unknown, stepNumber: number): SessionRuntimeHints | undefined { diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 91a67fd87..dfb096554 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -16,6 +16,7 @@ import type { } from './client-types.ts'; import { asRecord, + isRecord, readDeviceTarget, readNullableString, readOptionalString, @@ -155,13 +156,12 @@ function buildClientDevicePlatformFields( } export function normalizeRuntimeHints(value: unknown): SessionRuntimeHints | undefined { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; - const platform = record.platform; - const metroHost = readOptionalString(record, 'metroHost'); - const metroPort = typeof record.metroPort === 'number' ? record.metroPort : undefined; - const bundleUrl = readOptionalString(record, 'bundleUrl'); - const launchUrl = readOptionalString(record, 'launchUrl'); + if (!isRecord(value)) return undefined; + const platform = value.platform; + const metroHost = readOptionalString(value, 'metroHost'); + const metroPort = typeof value.metroPort === 'number' ? value.metroPort : undefined; + const bundleUrl = readOptionalString(value, 'bundleUrl'); + const launchUrl = readOptionalString(value, 'launchUrl'); return { platform: platform === 'ios' || platform === 'android' ? platform : undefined, metroHost, @@ -209,21 +209,20 @@ export function normalizeOpenDevice( } export function normalizeStartupSample(value: unknown): StartupPerfSample | undefined { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; + if (!isRecord(value)) return undefined; if ( - typeof record.durationMs !== 'number' || - typeof record.measuredAt !== 'string' || - typeof record.method !== 'string' + typeof value.durationMs !== 'number' || + typeof value.measuredAt !== 'string' || + typeof value.method !== 'string' ) { return undefined; } return { - durationMs: record.durationMs, - measuredAt: record.measuredAt, - method: record.method, - appTarget: readOptionalString(record, 'appTarget'), - appBundleId: readOptionalString(record, 'appBundleId'), + durationMs: value.durationMs, + measuredAt: value.measuredAt, + method: value.method, + appTarget: readOptionalString(value, 'appTarget'), + appBundleId: readOptionalString(value, 'appBundleId'), }; } diff --git a/src/client.ts b/src/client.ts index 40754b3c6..49acb94b5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -25,6 +25,7 @@ import { resolveSessionName, } from './client-normalizers.ts'; import { readScreenshotResultData } from './utils/screenshot-result.ts'; +import { isRecord } from './utils/parsing.ts'; import type { AgentDeviceClient, AgentDeviceClientConfig, @@ -133,10 +134,7 @@ export function createAgentDeviceClient( const shutdown = data.shutdown; return { session, - shutdown: - typeof shutdown === 'object' && shutdown !== null - ? (shutdown as Record) - : undefined, + shutdown: isRecord(shutdown) ? shutdown : undefined, identifiers: { session }, }; }, @@ -196,10 +194,7 @@ export function createAgentDeviceClient( return { session, closedApp: options.app, - shutdown: - typeof shutdown === 'object' && shutdown !== null - ? (shutdown as Record) - : undefined, + shutdown: isRecord(shutdown) ? shutdown : undefined, identifiers: { session }, }; }, @@ -374,9 +369,7 @@ function optionalSnapshotResponseFields( } function readObject(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null - ? (value as Record) - : undefined; + return isRecord(value) ? value : undefined; } function mergeClientOptions( @@ -388,18 +381,17 @@ function mergeClientOptions( function normalizeLease(data: Record): Lease { const rawLease = data.lease; - if (!rawLease || typeof rawLease !== 'object' || Array.isArray(rawLease)) { + if (!isRecord(rawLease)) { throw new Error('Invalid lease response from daemon'); } - const lease = rawLease as Record; return { - leaseId: readRequiredString(lease, 'leaseId'), - tenantId: readRequiredString(lease, 'tenantId'), - runId: readRequiredString(lease, 'runId'), - backend: readRequiredString(lease, 'backend') as Lease['backend'], - createdAt: typeof lease.createdAt === 'number' ? lease.createdAt : undefined, - heartbeatAt: typeof lease.heartbeatAt === 'number' ? lease.heartbeatAt : undefined, - expiresAt: typeof lease.expiresAt === 'number' ? lease.expiresAt : undefined, + leaseId: readRequiredString(rawLease, 'leaseId'), + tenantId: readRequiredString(rawLease, 'tenantId'), + runId: readRequiredString(rawLease, 'runId'), + backend: readRequiredString(rawLease, 'backend') as Lease['backend'], + createdAt: typeof rawLease.createdAt === 'number' ? rawLease.createdAt : undefined, + heartbeatAt: typeof rawLease.heartbeatAt === 'number' ? rawLease.heartbeatAt : undefined, + expiresAt: typeof rawLease.expiresAt === 'number' ? rawLease.expiresAt : undefined, }; } diff --git a/src/commands/batch/metadata.ts b/src/commands/batch/metadata.ts index 935c77ebd..1902ab3a0 100644 --- a/src/commands/batch/metadata.ts +++ b/src/commands/batch/metadata.ts @@ -21,6 +21,7 @@ import { type CommandFieldMap, type InferCommandInput, } from '../command-input.ts'; +import { isRecord } from '../../utils/parsing.ts'; export type BatchCommandStep = { command: string; @@ -146,18 +147,18 @@ function readBatchStepCommand( } function readBatchStepRecord(step: unknown, stepNumber: number): Record { - if (!step || typeof step !== 'object' || Array.isArray(step)) { + if (!isRecord(step)) { throw new Error(`Invalid batch step ${stepNumber}.`); } - return step as Record; + return step; } function readBatchStepInput(record: Record, stepNumber: number) { const input = record.input; - if (!input || typeof input !== 'object' || Array.isArray(input)) { + if (!isRecord(input)) { throw new Error(`Batch step ${stepNumber} input must be an object.`); } - return input as Record; + return input; } function readBatchStepRuntimeProperty( diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts index e492d0d3c..6a96bbb45 100644 --- a/src/commands/batch/projection.ts +++ b/src/commands/batch/projection.ts @@ -6,6 +6,7 @@ import { } from '../../batch-policy.ts'; import type { DaemonBatchStep } from '../../core/batch.ts'; import { AppError } from '../../utils/errors.ts'; +import { isRecord } from '../../utils/parsing.ts'; import { request } from '../cli-grammar/common.ts'; import type { CommandInput, DaemonCommandRequest, DaemonWriter } from '../cli-grammar/types.ts'; import { buildRequestFlags } from '../command-flags.ts'; @@ -64,10 +65,10 @@ function readBatchDaemonStep( } function readBatchStepRecord(step: unknown, stepNumber: number): Record { - if (!step || typeof step !== 'object' || Array.isArray(step)) { + if (!isRecord(step)) { throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`); } - return step as Record; + return step; } function readBatchStepCommand( @@ -79,7 +80,7 @@ function readBatchStepCommand( function readBatchStepInput(record: Record, stepNumber: number): CommandInput { const input = record.input; - if (!input || typeof input !== 'object' || Array.isArray(input)) { + if (!isRecord(input)) { throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`); } return input as CommandInput; diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index 15c72a464..a6cdb6169 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -46,7 +46,7 @@ type NetworkCliResult = { include?: NetworkIncludeMode; scannedLines?: number; matchedLines?: number; - entries: readonly NetworkCliEntry[]; + entries?: readonly NetworkCliEntry[]; notes?: readonly string[]; }; @@ -65,11 +65,12 @@ function logsCliOutput(data: LogsCliResult): CliOutput { function networkCliOutput(data: NetworkCliResult): CliOutput { const lines: string[] = []; + const entries = data.entries ?? []; if (data.path) lines.push(data.path); - if (data.entries.length === 0) { + if (entries.length === 0) { lines.push('No recent HTTP(s) entries found.'); } else { - for (const entry of data.entries) { + for (const entry of entries) { lines.push(...formatNetworkEntry(entry)); } } @@ -103,8 +104,8 @@ function formatActionFields(data: LogsActionFields): string | undefined { ); } -function formatActionField(key: string, value: true | number | undefined): string { - return value === undefined ? '' : `${key}=${value}`; +function formatActionField(key: string, value: true | number | null | undefined): string { + return value == null ? '' : `${key}=${value}`; } function formatNetworkEntry(entry: NetworkCliEntry): string[] { @@ -143,7 +144,7 @@ function formatKeyValueFields (data[key] !== undefined ? `${key}=${data[key]}` : '')) + .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) .filter(Boolean) .join(' '); return text || undefined; diff --git a/src/commands/perf/output.ts b/src/commands/perf/output.ts index 52537c08a..12f233982 100644 --- a/src/commands/perf/output.ts +++ b/src/commands/perf/output.ts @@ -1,4 +1,5 @@ import type { CommandRequestResult } from '../../client-types.ts'; +import { isRecord } from '../../utils/parsing.ts'; import type { CliOutput } from '../command-contract.ts'; import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; @@ -14,21 +15,12 @@ export const perfCliOutputFormatters = { function formatPerfCliOutput(data: Record): string { const nativeOutput = formatNativePerfOutput(data); if (nativeOutput) return nativeOutput; - const artifact = - data.artifact !== null && typeof data.artifact === 'object' && !Array.isArray(data.artifact) - ? (data.artifact as Record) - : undefined; + const artifact = isRecord(data.artifact) ? data.artifact : undefined; if (artifact) { return formatMemoryArtifactSummary(artifact); } - const metrics = - data.metrics !== null && typeof data.metrics === 'object' && !Array.isArray(data.metrics) - ? (data.metrics as Record) - : undefined; - const fps = - metrics?.fps !== null && typeof metrics?.fps === 'object' && !Array.isArray(metrics.fps) - ? (metrics.fps as Record) - : undefined; + const metrics = isRecord(data.metrics) ? data.metrics : undefined; + const fps = isRecord(metrics?.fps) ? metrics.fps : undefined; const resourceSummary = buildResourcePerfSummary(metrics); if (!fps) { return formatPerfUnavailable(resourceSummary, 'missing frame metric'); @@ -129,16 +121,8 @@ function readString(value: unknown): string | undefined { } function formatNativePerfFrameHealth(data: Record): string { - const summary = - data.summary !== null && typeof data.summary === 'object' && !Array.isArray(data.summary) - ? (data.summary as Record) - : undefined; - const frameHealth = - summary?.frameHealth !== null && - typeof summary?.frameHealth === 'object' && - !Array.isArray(summary.frameHealth) - ? (summary.frameHealth as Record) - : undefined; + const summary = isRecord(data.summary) ? data.summary : undefined; + const frameHealth = isRecord(summary?.frameHealth) ? summary.frameHealth : undefined; if (!frameHealth || frameHealth.available !== true) return ''; const droppedFramePercent = readFiniteNumber(frameHealth.droppedFramePercent); const droppedFrameCount = readFiniteNumber(frameHealth.droppedFrameCount); @@ -191,9 +175,8 @@ function formatSampleWindow(sampleWindowMs: number | undefined): string { function formatWorstFrameWindows(fps: Record): string[] { if (!Array.isArray(fps.worstWindows)) return []; return fps.worstWindows.flatMap((entry) => { - if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return []; - const window = entry as Record; - const line = formatWorstFrameWindow(window); + if (!isRecord(entry)) return []; + const line = formatWorstFrameWindow(entry); return line ? [line] : []; }); } @@ -214,16 +197,8 @@ function formatWorstFrameWindow(window: Record): string | undef function buildResourcePerfSummary( metrics: Record | undefined, ): string | undefined { - const cpu = - metrics?.cpu !== null && typeof metrics?.cpu === 'object' && !Array.isArray(metrics.cpu) - ? (metrics.cpu as Record) - : undefined; - const memory = - metrics?.memory !== null && - typeof metrics?.memory === 'object' && - !Array.isArray(metrics.memory) - ? (metrics.memory as Record) - : undefined; + const cpu = isRecord(metrics?.cpu) ? metrics.cpu : undefined; + const memory = isRecord(metrics?.memory) ? metrics.memory : undefined; const parts = [formatCpuPerfSummary(cpu), formatMemoryPerfSummary(memory)].filter( (part): part is string => Boolean(part), ); diff --git a/src/core/batch.ts b/src/core/batch.ts index f58b109fe..34b98cc31 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -1,5 +1,6 @@ import { daemonRuntimeSchema, type DaemonRequest, type DaemonResponse } from '../contracts.ts'; import { AppError, asAppError } from '../utils/errors.ts'; +import { isRecord } from '../utils/parsing.ts'; import { DEFAULT_BATCH_MAX_STEPS } from '../batch-contract.ts'; import { BATCH_BLOCKED_COMMANDS, @@ -140,8 +141,8 @@ export function validateAndNormalizeBatchSteps( const normalized: NormalizedBatchStep[] = []; for (let index = 0; index < steps.length; index += 1) { - const step = steps[index] as Partial; - if (!step || typeof step !== 'object') { + const step = steps[index]; + if (!isRecord(step)) { throw new AppError('INVALID_ARGS', `Invalid batch step at index ${index}.`); } const unknownKeys = Object.keys(step).filter((key) => !batchAllowedStepKeys.has(key)); @@ -167,10 +168,7 @@ export function validateAndNormalizeBatchSteps( `Batch step ${index + 1} positionals must contain only strings.`, ); } - if ( - step.flags !== undefined && - (typeof step.flags !== 'object' || Array.isArray(step.flags) || !step.flags) - ) { + if (step.flags !== undefined && !isRecord(step.flags)) { throw new AppError('INVALID_ARGS', `Batch step ${index + 1} flags must be an object.`); } normalized.push({ diff --git a/src/platforms/ios/debug-symbols/crash-artifact.ts b/src/platforms/ios/debug-symbols/crash-artifact.ts index 91b364d35..44963f623 100644 --- a/src/platforms/ios/debug-symbols/crash-artifact.ts +++ b/src/platforms/ios/debug-symbols/crash-artifact.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { isRecord } from '../../../utils/parsing.ts'; import type { AppleImage, CrashArtifact, @@ -64,14 +65,8 @@ function readIpsFrameMatches(rawThreads: unknown[], images: AppleImage[]): IpsFr } function readIpsFrameRecords(thread: unknown): Record[] { - if (!thread || typeof thread !== 'object') return []; - const frames = (thread as Record).frames; - return Array.isArray(frames) - ? frames.filter( - (frame): frame is Record => - frame !== null && typeof frame === 'object' && !Array.isArray(frame), - ) - : []; + if (!isRecord(thread) || !Array.isArray(thread.frames)) return []; + return thread.frames.filter(isRecord); } function readIpsFrameMatch( @@ -113,18 +108,17 @@ function writeIpsFrameSymbol(frame: Record, symbol: string): vo } function readIpsImage(value: unknown, index: number): AppleImage[] { - if (!value || typeof value !== 'object') return []; - const record = value as Record; - const uuid = normalizeUuid(readString(record.uuid)); - const base = readBigIntField(record, 'base', 'IPS usedImages'); + if (!isRecord(value)) return []; + const uuid = normalizeUuid(readString(value.uuid)); + const base = readBigIntField(value, 'base', 'IPS usedImages'); if (!uuid || base === undefined) return []; - const pathValue = readString(record.path); + const pathValue = readString(value.path); return [ { index, - name: readString(record.name) ?? (pathValue ? path.basename(pathValue) : `image-${index}`), + name: readString(value.name) ?? (pathValue ? path.basename(pathValue) : `image-${index}`), uuid, - arch: readString(record.arch), + arch: readString(value.arch), base, path: pathValue, }, diff --git a/src/platforms/ios/debug-symbols/report.ts b/src/platforms/ios/debug-symbols/report.ts index e90e4c66d..313166f20 100644 --- a/src/platforms/ios/debug-symbols/report.ts +++ b/src/platforms/ios/debug-symbols/report.ts @@ -7,6 +7,7 @@ import type { SymbolicatedAddress, TextFrameMatch, } from './types.ts'; +import { isRecord } from '../../../utils/parsing.ts'; import { addressKey, compactJoin, @@ -73,12 +74,7 @@ function readIpsBundleId( payload: Record, header: Record | null, ): string | undefined { - const bundleInfo = - payload.bundleInfo !== null && - typeof payload.bundleInfo === 'object' && - !Array.isArray(payload.bundleInfo) - ? (payload.bundleInfo as Record) - : undefined; + const bundleInfo = isRecord(payload.bundleInfo) ? payload.bundleInfo : undefined; return firstString(bundleInfo?.CFBundleIdentifier, header?.bundleID); } @@ -86,12 +82,7 @@ function readIpsVersion( payload: Record, header: Record | null, ): string | undefined { - const bundleInfo = - payload.bundleInfo !== null && - typeof payload.bundleInfo === 'object' && - !Array.isArray(payload.bundleInfo) - ? (payload.bundleInfo as Record) - : undefined; + const bundleInfo = isRecord(payload.bundleInfo) ? payload.bundleInfo : undefined; return firstString(bundleInfo?.CFBundleShortVersionString, header?.app_version); } @@ -110,10 +101,7 @@ function readIpsTimestamp( } function readIpsExceptionType(exception: unknown): string | undefined { - if (exception === null || typeof exception !== 'object' || Array.isArray(exception)) { - return undefined; - } - return readString((exception as Record).type); + return isRecord(exception) ? readString(exception.type) : undefined; } function readIpsHeader(header: string | undefined): Record | null { @@ -125,32 +113,21 @@ function readIpsCrashedThread(payload: Record): number | undefi if (faultingThread !== undefined) return faultingThread; const threads = Array.isArray(payload.threads) ? payload.threads : []; const triggeredIndex = threads.findIndex( - (thread) => - thread !== null && - typeof thread === 'object' && - !Array.isArray(thread) && - (thread as Record).triggered === true, + (thread) => isRecord(thread) && thread.triggered === true, ); return triggeredIndex === -1 ? undefined : triggeredIndex; } function readIpsExceptionCodes(exception: unknown): string | undefined { - if (exception === null || typeof exception !== 'object' || Array.isArray(exception)) { - return undefined; - } - const record = exception as Record; - return firstString(record.codes, record.rawCodes); + return isRecord(exception) ? firstString(exception.codes, exception.rawCodes) : undefined; } function readIpsTerminationReason(termination: unknown): string | undefined { - if (termination === null || typeof termination !== 'object' || Array.isArray(termination)) { - return undefined; - } - const record = termination as Record; + if (!isRecord(termination)) return undefined; return compactJoin([ - readString(record.namespace), - readString(record.code), - readString(record.reason), + readString(termination.namespace), + readString(termination.code), + readString(termination.reason), ]); } diff --git a/src/platforms/ios/debug-symbols/utils.ts b/src/platforms/ios/debug-symbols/utils.ts index 5e0f55032..e08835374 100644 --- a/src/platforms/ios/debug-symbols/utils.ts +++ b/src/platforms/ios/debug-symbols/utils.ts @@ -1,4 +1,5 @@ import { AppError } from '../../../utils/errors.ts'; +import { isRecord } from '../../../utils/parsing.ts'; const UUID_RE = /^[0-9a-fA-F-]{32,36}$/; @@ -35,9 +36,7 @@ export function compactJoin(values: (string | undefined)[]): string | undefined export function readJsonRecord(text: string): Record | null { try { const value = JSON.parse(text); - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; + return isRecord(value) ? value : null; } catch { return null; } diff --git a/src/platforms/ios/runner-xctestrun-products.ts b/src/platforms/ios/runner-xctestrun-products.ts index 2289e5f69..80a0d0959 100644 --- a/src/platforms/ios/runner-xctestrun-products.ts +++ b/src/platforms/ios/runner-xctestrun-products.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { readApplePlistJson } from './tool-provider.ts'; import { parseXmlDocumentSync, visitXmlPlistEntries, type XmlNode } from './xml.ts'; +import { isRecord } from '../../utils/parsing.ts'; const XCTESTRUN_PRODUCT_REFERENCE_KEYS = new Set([ 'ProductPaths', @@ -129,18 +130,10 @@ function collectConfiguredTestTargets(parsed: Record): Record[] = []; for (const config of testConfigurations) { - if (config === null || typeof config !== 'object' || Array.isArray(config)) { - continue; - } - const configRecord = config as Record; - const testTargets = configRecord.TestTargets; + if (!isRecord(config)) continue; + const testTargets = config.TestTargets; if (Array.isArray(testTargets)) { - targets.push( - ...testTargets.filter( - (target): target is Record => - target !== null && typeof target === 'object' && !Array.isArray(target), - ), - ); + targets.push(...testTargets.filter(isRecord)); } } return targets; @@ -148,11 +141,7 @@ function collectConfiguredTestTargets(parsed: Record): Record): Record[] { return Object.values(parsed).filter( - (value): value is Record => - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - 'TestBundlePath' in value, + (value): value is Record => isRecord(value) && 'TestBundlePath' in value, ); } diff --git a/src/snapshot-diagnostics.ts b/src/snapshot-diagnostics.ts index fba4a37c4..64ec2dd2e 100644 --- a/src/snapshot-diagnostics.ts +++ b/src/snapshot-diagnostics.ts @@ -1,5 +1,6 @@ import type { SnapshotBackend } from './utils/snapshot.ts'; import type { Platform } from './utils/device.ts'; +import { isRecord } from './utils/parsing.ts'; const SLOW_SNAPSHOT_P95_WARNING_MS = 1_500; @@ -78,11 +79,10 @@ export function mergeSnapshotDiagnostics( export function readSnapshotDiagnosticsSummary( value: unknown, ): SnapshotDiagnosticsSummary | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; - const stats = readSnapshotTimingStats(record.stats); + if (!isRecord(value)) return undefined; + const stats = readSnapshotTimingStats(value.stats); if (!stats) return undefined; - const warning = typeof record.warning === 'string' ? record.warning : undefined; + const warning = typeof value.warning === 'string' ? value.warning : undefined; return { stats, ...(warning ? { warning } : {}) }; } @@ -128,13 +128,12 @@ function formatSlowSnapshotWarning(stats: SnapshotTimingStats): string { } function readSnapshotTimingStats(value: unknown): SnapshotTimingStats | undefined { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; - const required = readRequiredSnapshotTimingStats(record); + if (!isRecord(value)) return undefined; + const required = readRequiredSnapshotTimingStats(value); if (!required) return undefined; return { ...required, - ...readOptionalSnapshotTimingStats(record), + ...readOptionalSnapshotTimingStats(value), }; } @@ -168,12 +167,7 @@ function readOptionalSnapshotTimingStats( record: Record, ): Pick { const platform = typeof record.platform === 'string' ? record.platform : undefined; - const backends = - record.backends !== null && - typeof record.backends === 'object' && - !Array.isArray(record.backends) - ? readBackendCounts(record.backends as Record) - : undefined; + const backends = isRecord(record.backends) ? readBackendCounts(record.backends) : undefined; return { ...(platform ? { platform: platform as SnapshotTimingStats['platform'] } : {}), ...(backends ? { backends } : {}), diff --git a/src/utils/parsing.ts b/src/utils/parsing.ts index 4c55c82bd..05b30e9ff 100644 --- a/src/utils/parsing.ts +++ b/src/utils/parsing.ts @@ -73,12 +73,11 @@ export function readDeviceTarget(record: Record, key: string): export function readRect(record: Record, key: string): Rect | undefined { const value = record[key]; - if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; - const valueRecord = value as Record; - const x = readNumberField(valueRecord, 'x'); - const y = readNumberField(valueRecord, 'y'); - const width = readNumberField(valueRecord, 'width'); - const height = readNumberField(valueRecord, 'height'); + if (!isRecord(value)) return undefined; + const x = readNumberField(value, 'x'); + const y = readNumberField(value, 'y'); + const width = readNumberField(value, 'width'); + const height = readNumberField(value, 'height'); if (x === undefined || y === undefined || width === undefined || height === undefined) { return undefined; } @@ -87,10 +86,9 @@ export function readRect(record: Record, key: string): Rect | u export function readPoint(record: Record, key: string): Point | undefined { const value = record[key]; - if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; - const valueRecord = value as Record; - const x = readNumberField(valueRecord, 'x'); - const y = readNumberField(valueRecord, 'y'); + if (!isRecord(value)) return undefined; + const x = readNumberField(value, 'x'); + const y = readNumberField(value, 'y'); if (x === undefined || y === undefined) { return undefined; } @@ -129,12 +127,16 @@ function parseDeviceTarget(value: unknown): DeviceTarget | undefined { } export function asRecord(value: unknown): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { + if (!isRecord(value)) { throw new AppError('COMMAND_FAILED', 'Daemon returned an unexpected response shape.', { value, }); } - return value as Record; + return value; +} + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); } export function stripUndefined>(value: T): T { diff --git a/src/utils/screenshot-result.ts b/src/utils/screenshot-result.ts index dad052e0a..7a9f64c06 100644 --- a/src/utils/screenshot-result.ts +++ b/src/utils/screenshot-result.ts @@ -1,5 +1,5 @@ import type { ScreenshotOverlayRef } from './snapshot.ts'; -import { readPoint, readRect } from './parsing.ts'; +import { isRecord, readPoint, readRect } from './parsing.ts'; export type ScreenshotResultData = { path?: string; @@ -7,11 +7,10 @@ export type ScreenshotResultData = { }; export function readScreenshotResultData(value: unknown): ScreenshotResultData | undefined { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; - const path = typeof record.path === 'string' ? record.path : undefined; - const overlayRefs = Array.isArray(record.overlayRefs) - ? record.overlayRefs.flatMap((entry) => { + if (!isRecord(value)) return undefined; + const path = typeof value.path === 'string' ? value.path : undefined; + const overlayRefs = Array.isArray(value.overlayRefs) + ? value.overlayRefs.flatMap((entry) => { const overlayRef = readScreenshotOverlayRef(entry); return overlayRef ? [overlayRef] : []; }) @@ -23,16 +22,15 @@ export function readScreenshotResultData(value: unknown): ScreenshotResultData | } function readScreenshotOverlayRef(value: unknown): ScreenshotOverlayRef | undefined { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; - const ref = typeof record.ref === 'string' && record.ref.length > 0 ? record.ref : undefined; - const rect = readRect(record, 'rect'); - const overlayRect = readRect(record, 'overlayRect'); - const center = readPoint(record, 'center'); + if (!isRecord(value)) return undefined; + const ref = typeof value.ref === 'string' && value.ref.length > 0 ? value.ref : undefined; + const rect = readRect(value, 'rect'); + const overlayRect = readRect(value, 'overlayRect'); + const center = readPoint(value, 'center'); if (!ref || !rect || !overlayRect || !center) return undefined; return { ref, - ...(typeof record.label === 'string' && record.label.length > 0 ? { label: record.label } : {}), + ...(typeof value.label === 'string' && value.label.length > 0 ? { label: value.label } : {}), rect, overlayRect, center, From b3a715e9b27830bc58910c76bceccdaf13ea394e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:51:15 +0200 Subject: [PATCH 10/13] refactor: split daemon output parsers --- src/commands/perf/output.ts | 8 ++++++++ src/utils/screenshot-result.ts | 34 ++++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/commands/perf/output.ts b/src/commands/perf/output.ts index 12f233982..ec39cc40e 100644 --- a/src/commands/perf/output.ts +++ b/src/commands/perf/output.ts @@ -20,6 +20,10 @@ function formatPerfCliOutput(data: Record): string { return formatMemoryArtifactSummary(artifact); } const metrics = isRecord(data.metrics) ? data.metrics : undefined; + return formatFramePerfOutput(metrics); +} + +function formatFramePerfOutput(metrics: Record | undefined): string { const fps = isRecord(metrics?.fps) ? metrics.fps : undefined; const resourceSummary = buildResourcePerfSummary(metrics); if (!fps) { @@ -33,6 +37,10 @@ function formatPerfCliOutput(data: Record): string { const frameSummary = formatFrameHealthSummary(fps); if (!frameSummary) return formatPerfUnavailable(resourceSummary, 'missing dropped-frame summary'); + return formatFrameHealthOutput(fps, frameSummary); +} + +function formatFrameHealthOutput(fps: Record, frameSummary: string): string { const lines = [`Frame health: ${frameSummary}`]; const worstWindows = formatWorstFrameWindows(fps); if (worstWindows.length > 0) { diff --git a/src/utils/screenshot-result.ts b/src/utils/screenshot-result.ts index 7a9f64c06..36dafea71 100644 --- a/src/utils/screenshot-result.ts +++ b/src/utils/screenshot-result.ts @@ -1,5 +1,5 @@ import type { ScreenshotOverlayRef } from './snapshot.ts'; -import { isRecord, readPoint, readRect } from './parsing.ts'; +import { isRecord, readOptionalString, readPoint, readRect } from './parsing.ts'; export type ScreenshotResultData = { path?: string; @@ -23,16 +23,30 @@ export function readScreenshotResultData(value: unknown): ScreenshotResultData | function readScreenshotOverlayRef(value: unknown): ScreenshotOverlayRef | undefined { if (!isRecord(value)) return undefined; - const ref = typeof value.ref === 'string' && value.ref.length > 0 ? value.ref : undefined; - const rect = readRect(value, 'rect'); - const overlayRect = readRect(value, 'overlayRect'); - const center = readPoint(value, 'center'); - if (!ref || !rect || !overlayRect || !center) return undefined; + const ref = readOptionalString(value, 'ref'); + const geometry = readScreenshotOverlayGeometry(value); + if (!ref || !geometry) return undefined; return { ref, - ...(typeof value.label === 'string' && value.label.length > 0 ? { label: value.label } : {}), - rect, - overlayRect, - center, + ...readScreenshotOverlayLabel(value), + ...geometry, }; } + +function readScreenshotOverlayGeometry( + record: Record, +): Pick | undefined { + const rect = readRect(record, 'rect'); + if (!rect) return undefined; + const overlayRect = readRect(record, 'overlayRect'); + if (!overlayRect) return undefined; + const center = readPoint(record, 'center'); + return center ? { rect, overlayRect, center } : undefined; +} + +function readScreenshotOverlayLabel( + record: Record, +): Pick { + const label = readOptionalString(record, 'label'); + return label ? { label } : {}; +} From 4785c6afd5112bf4efd15534939adc70ffc0becb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:57:53 +0200 Subject: [PATCH 11/13] refactor: narrow screenshot overlay parser --- src/utils/screenshot-result.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/utils/screenshot-result.ts b/src/utils/screenshot-result.ts index 36dafea71..aff289f07 100644 --- a/src/utils/screenshot-result.ts +++ b/src/utils/screenshot-result.ts @@ -1,5 +1,5 @@ import type { ScreenshotOverlayRef } from './snapshot.ts'; -import { isRecord, readOptionalString, readPoint, readRect } from './parsing.ts'; +import { isRecord, readPoint, readRect } from './parsing.ts'; export type ScreenshotResultData = { path?: string; @@ -10,7 +10,7 @@ export function readScreenshotResultData(value: unknown): ScreenshotResultData | if (!isRecord(value)) return undefined; const path = typeof value.path === 'string' ? value.path : undefined; const overlayRefs = Array.isArray(value.overlayRefs) - ? value.overlayRefs.flatMap((entry) => { + ? value.overlayRefs.filter(isRecord).flatMap((entry) => { const overlayRef = readScreenshotOverlayRef(entry); return overlayRef ? [overlayRef] : []; }) @@ -21,14 +21,15 @@ export function readScreenshotResultData(value: unknown): ScreenshotResultData | }; } -function readScreenshotOverlayRef(value: unknown): ScreenshotOverlayRef | undefined { - if (!isRecord(value)) return undefined; - const ref = readOptionalString(value, 'ref'); - const geometry = readScreenshotOverlayGeometry(value); - if (!ref || !geometry) return undefined; +function readScreenshotOverlayRef( + record: Record, +): ScreenshotOverlayRef | undefined { + if (typeof record.ref !== 'string' || record.ref.length === 0) return undefined; + const geometry = readScreenshotOverlayGeometry(record); + if (!geometry) return undefined; return { - ref, - ...readScreenshotOverlayLabel(value), + ref: record.ref, + ...readScreenshotOverlayLabel(record), ...geometry, }; } @@ -47,6 +48,5 @@ function readScreenshotOverlayGeometry( function readScreenshotOverlayLabel( record: Record, ): Pick { - const label = readOptionalString(record, 'label'); - return label ? { label } : {}; + return typeof record.label === 'string' && record.label.length > 0 ? { label: record.label } : {}; } From 3c4dbc8ecd0890be07f9515cc70e7ec42599e6eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 16:01:56 +0200 Subject: [PATCH 12/13] refactor: type screenshot overlay data --- src/utils/parsing.ts | 6 ++---- src/utils/screenshot-result.ts | 28 ++++++++++++++++++++-------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/utils/parsing.ts b/src/utils/parsing.ts index 05b30e9ff..c85c71e2f 100644 --- a/src/utils/parsing.ts +++ b/src/utils/parsing.ts @@ -71,8 +71,7 @@ export function readDeviceTarget(record: Record, key: string): return readOptional(record, key, parseDeviceTarget) ?? 'mobile'; } -export function readRect(record: Record, key: string): Rect | undefined { - const value = record[key]; +export function parseRect(value: unknown): Rect | undefined { if (!isRecord(value)) return undefined; const x = readNumberField(value, 'x'); const y = readNumberField(value, 'y'); @@ -84,8 +83,7 @@ export function readRect(record: Record, key: string): Rect | u return { x, y, width, height }; } -export function readPoint(record: Record, key: string): Point | undefined { - const value = record[key]; +export function parsePoint(value: unknown): Point | undefined { if (!isRecord(value)) return undefined; const x = readNumberField(value, 'x'); const y = readNumberField(value, 'y'); diff --git a/src/utils/screenshot-result.ts b/src/utils/screenshot-result.ts index aff289f07..cd912f012 100644 --- a/src/utils/screenshot-result.ts +++ b/src/utils/screenshot-result.ts @@ -1,16 +1,24 @@ import type { ScreenshotOverlayRef } from './snapshot.ts'; -import { isRecord, readPoint, readRect } from './parsing.ts'; +import { isRecord, parsePoint, parseRect } from './parsing.ts'; export type ScreenshotResultData = { path?: string; overlayRefs?: ScreenshotOverlayRef[]; }; +type ScreenshotOverlayRefData = { + ref?: unknown; + label?: unknown; + rect?: unknown; + overlayRect?: unknown; + center?: unknown; +}; + export function readScreenshotResultData(value: unknown): ScreenshotResultData | undefined { if (!isRecord(value)) return undefined; const path = typeof value.path === 'string' ? value.path : undefined; const overlayRefs = Array.isArray(value.overlayRefs) - ? value.overlayRefs.filter(isRecord).flatMap((entry) => { + ? value.overlayRefs.filter(isScreenshotOverlayRefData).flatMap((entry) => { const overlayRef = readScreenshotOverlayRef(entry); return overlayRef ? [overlayRef] : []; }) @@ -22,7 +30,7 @@ export function readScreenshotResultData(value: unknown): ScreenshotResultData | } function readScreenshotOverlayRef( - record: Record, + record: ScreenshotOverlayRefData, ): ScreenshotOverlayRef | undefined { if (typeof record.ref !== 'string' || record.ref.length === 0) return undefined; const geometry = readScreenshotOverlayGeometry(record); @@ -35,18 +43,22 @@ function readScreenshotOverlayRef( } function readScreenshotOverlayGeometry( - record: Record, + record: ScreenshotOverlayRefData, ): Pick | undefined { - const rect = readRect(record, 'rect'); + const rect = parseRect(record.rect); if (!rect) return undefined; - const overlayRect = readRect(record, 'overlayRect'); + const overlayRect = parseRect(record.overlayRect); if (!overlayRect) return undefined; - const center = readPoint(record, 'center'); + const center = parsePoint(record.center); return center ? { rect, overlayRect, center } : undefined; } function readScreenshotOverlayLabel( - record: Record, + record: ScreenshotOverlayRefData, ): Pick { return typeof record.label === 'string' && record.label.length > 0 ? { label: record.label } : {}; } + +function isScreenshotOverlayRefData(value: unknown): value is ScreenshotOverlayRefData { + return isRecord(value); +} From e4718f69d5ff6b15e0b3f71abfac4943c94d5099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 16:14:49 +0200 Subject: [PATCH 13/13] refactor: type shutdown result payload --- src/__tests__/client.test.ts | 41 +++++++++++++++++++++++++++++++++ src/client-normalizers.ts | 36 ++++++++++++++++++++++++++++- src/client-types.ts | 6 +++-- src/client.ts | 7 +++--- src/daemon/target-shutdown.ts | 9 ++------ src/index.ts | 1 + src/target-shutdown-contract.ts | 9 ++++++++ 7 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 src/target-shutdown-contract.ts diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 7361d8fbf..3c8020e49 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -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') { diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index dfb096554..213fa90ab 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -1,7 +1,7 @@ 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 { AppError, type NormalizedError } from './utils/errors.ts'; import type { SnapshotNode } from './utils/snapshot.ts'; import { buildAppIdentifiers, buildDeviceIdentifiers } from './client-shared.ts'; import type { @@ -13,6 +13,7 @@ import type { InternalRequestOptions, MaterializationReleaseResult, StartupPerfSample, + TargetShutdownResult, } from './client-types.ts'; import { asRecord, @@ -226,6 +227,39 @@ 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[]) : []; diff --git a/src/client-types.ts b/src/client-types.ts index 6ca2e05c3..f414478f4 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -40,6 +40,8 @@ import type { AppsFilter } from './contracts/app-inventory.ts'; import type { ScreenshotRequestFlags } from './contracts/screenshot.ts'; import type { BatchRunResult, DaemonBatchStep } from './core/batch.ts'; export type { BatchRunResult } from './core/batch.ts'; +import type { TargetShutdownResult } from './target-shutdown-contract.ts'; +export type { TargetShutdownResult } from './target-shutdown-contract.ts'; import type { PerfAction, PerfArea, PerfKind, PerfSubject } from './contracts/perf.ts'; import type { AlertAction, AlertInfo } from './alert-contract.ts'; import type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts'; @@ -170,7 +172,7 @@ export type StartupPerfSample = { export type SessionCloseResult = { session: string; - shutdown?: Record; + shutdown?: TargetShutdownResult; identifiers: AgentDeviceIdentifiers; }; @@ -227,7 +229,7 @@ export type AppCloseOptions = AgentDeviceRequestOverrides & { export type AppCloseResult = { session: string; closedApp?: string; - shutdown?: Record; + shutdown?: TargetShutdownResult; identifiers: AgentDeviceIdentifiers; }; diff --git a/src/client.ts b/src/client.ts index 49acb94b5..6b6b95826 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,6 +19,7 @@ import { normalizeRuntimeHints, normalizeSession, normalizeStartupSample, + normalizeTargetShutdownResult, readOptionalString, readRequiredString, readSnapshotNodes, @@ -131,10 +132,9 @@ export function createAgentDeviceClient( close: async (options = {}) => { const session = resolveRequestSession(options); const data = await executeCommand>('close', options); - const shutdown = data.shutdown; return { session, - shutdown: isRecord(shutdown) ? shutdown : undefined, + shutdown: normalizeTargetShutdownResult(data.shutdown), identifiers: { session }, }; }, @@ -190,11 +190,10 @@ export function createAgentDeviceClient( close: async (options: AppCloseOptions = {}) => { const session = resolveRequestSession(options); const data = await executeCommand>('close', options); - const shutdown = data.shutdown; return { session, closedApp: options.app, - shutdown: isRecord(shutdown) ? shutdown : undefined, + shutdown: normalizeTargetShutdownResult(data.shutdown), identifiers: { session }, }; }, diff --git a/src/daemon/target-shutdown.ts b/src/daemon/target-shutdown.ts index e4a20c2c7..eb8ce5e07 100644 --- a/src/daemon/target-shutdown.ts +++ b/src/daemon/target-shutdown.ts @@ -1,16 +1,11 @@ import { runAndroidAdb } from '../platforms/android/adb.ts'; import { getSimulatorState, shutdownSimulator } from '../platforms/ios/simulator.ts'; +import type { TargetShutdownResult } from '../target-shutdown-contract.ts'; import type { DeviceInfo } from '../utils/device.ts'; import { normalizeError } from '../utils/errors.ts'; import { isAndroidEmulator, isIosSimulator } from './device-targets.ts'; -export type DeviceTargetShutdownResult = { - success: boolean; - exitCode: number; - stdout: string; - stderr: string; - error?: ReturnType; -}; +export type DeviceTargetShutdownResult = TargetShutdownResult; export function canShutdownDeviceTarget(device: DeviceInfo): boolean { return isIosSimulator(device) || isAndroidEmulator(device); diff --git a/src/index.ts b/src/index.ts index 937266080..c5c188869 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,6 +103,7 @@ export type { SettingsUpdateOptions, StartupPerfSample, SwipeOptions, + TargetShutdownResult, TraceOptions, TypeTextOptions, WaitCommandOptions, diff --git a/src/target-shutdown-contract.ts b/src/target-shutdown-contract.ts new file mode 100644 index 000000000..eed1398dc --- /dev/null +++ b/src/target-shutdown-contract.ts @@ -0,0 +1,9 @@ +import type { NormalizedError } from './utils/errors.ts'; + +export type TargetShutdownResult = { + success: boolean; + exitCode: number; + stdout: string; + stderr: string; + error?: NormalizedError; +};