From 6f1887f49dc663453a61b4397b9e7e498d2ff9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 27 Jun 2026 14:36:12 +0200 Subject: [PATCH] refactor: extract shared Android instrumentation-helper Both the snapshot helper and the multi-touch helper drive 'am instrument -w' and parse the same INSTRUMENTATION_STATUS/INSTRUMENTATION_RESULT key/value records, and both validate a bundled JSON manifest with the same integer and literal field rules. Extract the genuinely-identical parts into a shared src/platforms/android/instrumentation-helper.ts: - parseInstrumentationRecords (status + result record parsing) and the shared key/value reader - readInstrumentationResultNumber / readInstrumentationResultBoolean - readAndroidHelperManifestInteger / readAndroidHelperManifestLiteral (label-parameterized so each helper's exact error message is preserved) multitouch-helper now consumes the shared record parser (was its own ~25-line copy) and the shared numeric reader; snapshot-helper-capture re-exports the shared metadata readers under their existing public names so snapshot-helper-session keeps importing them unchanged. Behaviorless: the divergent, behavior-bearing parts are intentionally left in each consumer (install caches Set vs Map + install-policy/forget/retry, installed-versionCode regex + timeout, sha256File stream-vs-read, readString trim semantics and the sha256 validator that depends on it, artifact resolution + per-helper manifest schemas). --- .../android/instrumentation-helper.ts | 134 ++++++++++++++++++ src/platforms/android/multitouch-helper.ts | 61 +++----- .../android/snapshot-helper-artifact.ts | 40 +++--- .../android/snapshot-helper-capture.ts | 115 ++------------- 4 files changed, 177 insertions(+), 173 deletions(-) create mode 100644 src/platforms/android/instrumentation-helper.ts diff --git a/src/platforms/android/instrumentation-helper.ts b/src/platforms/android/instrumentation-helper.ts new file mode 100644 index 000000000..39984a310 --- /dev/null +++ b/src/platforms/android/instrumentation-helper.ts @@ -0,0 +1,134 @@ +import { AppError } from '../../utils/errors.ts'; + +// Shared primitives for the Android instrumentation helpers (snapshot + multi-touch). +// Both helpers drive `am instrument -w` and parse the resulting +// INSTRUMENTATION_STATUS / INSTRUMENTATION_RESULT key/value records, and both +// validate a bundled JSON manifest with the same integer/literal field rules. + +type AndroidInstrumentationRecordState = { + status: Array>; + results: Array>; + currentStatus: Record | null; + currentResult: Record | null; +}; + +export function parseInstrumentationRecords(output: string): { + status: Array>; + results: Array>; +} { + const state: AndroidInstrumentationRecordState = { + status: [], + results: [], + currentStatus: null, + currentResult: null, + }; + + for (const line of output.split(/\r?\n/)) { + readInstrumentationRecordLine(line, state); + } + flushInstrumentationRecords(state); + return { status: state.status, results: state.results }; +} + +function readInstrumentationRecordLine( + line: string, + state: AndroidInstrumentationRecordState, +): void { + if (line.startsWith('INSTRUMENTATION_STATUS: ')) { + state.currentStatus ??= {}; + readKeyValue(line.slice('INSTRUMENTATION_STATUS: '.length), state.currentStatus); + return; + } + if (line.startsWith('INSTRUMENTATION_STATUS_CODE: ')) { + flushStatusRecord(state); + return; + } + if (line.startsWith('INSTRUMENTATION_RESULT: ')) { + state.currentResult ??= {}; + readKeyValue(line.slice('INSTRUMENTATION_RESULT: '.length), state.currentResult); + return; + } + if (line.startsWith('INSTRUMENTATION_CODE: ')) { + flushResultRecord(state); + } +} + +function flushInstrumentationRecords(state: AndroidInstrumentationRecordState): void { + flushStatusRecord(state); + flushResultRecord(state); +} + +function flushStatusRecord(state: { + status: Array>; + currentStatus: Record | null; +}): void { + if (state.currentStatus) { + state.status.push(state.currentStatus); + state.currentStatus = null; + } +} + +function flushResultRecord(state: { + results: Array>; + currentResult: Record | null; +}): void { + if (state.currentResult) { + state.results.push(state.currentResult); + state.currentResult = null; + } +} + +function readKeyValue(line: string, target: Record): void { + const separator = line.indexOf('='); + if (separator < 0) { + return; + } + target[line.slice(0, separator)] = line.slice(separator + 1); +} + +export function readInstrumentationResultNumber(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function readInstrumentationResultBoolean(value: string | undefined): boolean | undefined { + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + return undefined; +} + +export function readAndroidHelperManifestInteger( + value: unknown, + field: string, + helperLabel: string, +): number { + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new AppError( + 'INVALID_ARGS', + `Android ${helperLabel} manifest ${field} must be an integer.`, + ); + } + return value; +} + +export function readAndroidHelperManifestLiteral( + value: unknown, + field: string, + expected: Value, + helperLabel: string, +): Value { + if (value !== expected) { + throw new AppError( + 'INVALID_ARGS', + `Android ${helperLabel} manifest ${field} must be "${expected}".`, + ); + } + return expected; +} diff --git a/src/platforms/android/multitouch-helper.ts b/src/platforms/android/multitouch-helper.ts index 309733720..371588602 100644 --- a/src/platforms/android/multitouch-helper.ts +++ b/src/platforms/android/multitouch-helper.ts @@ -16,6 +16,12 @@ import { type AndroidTouchGestureRequest, } from './adb-executor.ts'; import { getAndroidScreenSize, swipeAndroid } from './input-actions.ts'; +import { + parseInstrumentationRecords, + readAndroidHelperManifestInteger, + readAndroidHelperManifestLiteral, + readInstrumentationResultNumber, +} from './instrumentation-helper.ts'; const ANDROID_MULTITOUCH_HELPER_NAME = 'android-multitouch-helper'; const ANDROID_MULTITOUCH_HELPER_PACKAGE = 'com.callstack.agentdevice.multitouchhelper'; @@ -401,7 +407,7 @@ export async function runAndroidMultiTouchHelperGesture(options: { } export function parseAndroidMultiTouchHelperOutput(output: string): Record { - const finalResult = parseInstrumentationResults(output).find( + const finalResult = parseInstrumentationRecords(output).results.find( (record) => record.agentDeviceProtocol === ANDROID_MULTITOUCH_HELPER_PROTOCOL, ); if (!finalResult) { @@ -423,8 +429,8 @@ export function parseAndroidMultiTouchHelperOutput(output: string): Record): string { : finalResult.errorType || 'Android multi-touch helper returned an error'; } -function parseInstrumentationResults(output: string): Array> { - const results: Array> = []; - let current: Record | null = null; - for (const line of output.split(/\r?\n/)) { - if (line.startsWith('INSTRUMENTATION_RESULT: ')) { - current ??= {}; - readKeyValue(line.slice('INSTRUMENTATION_RESULT: '.length), current); - } else if (line.startsWith('INSTRUMENTATION_CODE: ') && current) { - results.push(current); - current = null; - } - } - if (current) results.push(current); - return results; -} - -function readKeyValue(line: string, target: Record): void { - const separator = line.indexOf('='); - if (separator >= 0) target[line.slice(0, separator)] = line.slice(separator + 1); -} - -function readOptionalNumber(value: string | undefined): number | undefined { - if (value === undefined) return undefined; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - async function resolveAndroidMultiTouchHelperArtifact(): Promise { const version = readVersion(); const helperDir = path.join(findProjectRoot(), 'android-multitouch-helper', 'dist'); @@ -622,23 +601,15 @@ function readString(value: unknown, field: string): string { } function readNumber(value: unknown, field: string): number { - if (!Number.isInteger(value)) { - throw new AppError( - 'INVALID_ARGS', - `Android multi-touch helper manifest ${field} must be an integer.`, - ); - } - return value as number; + return readAndroidHelperManifestInteger(value, field, 'multi-touch helper'); } -function readLiteral(value: unknown, field: string, expected: T): T { - if (value !== expected) { - throw new AppError( - 'INVALID_ARGS', - `Android multi-touch helper manifest ${field} must be "${expected}".`, - ); - } - return expected; +function readLiteral( + value: unknown, + field: string, + expected: Value, +): Value { + return readAndroidHelperManifestLiteral(value, field, expected, 'multi-touch helper'); } function readSha256(value: unknown): string { diff --git a/src/platforms/android/snapshot-helper-artifact.ts b/src/platforms/android/snapshot-helper-artifact.ts index 795284c42..55efdab42 100644 --- a/src/platforms/android/snapshot-helper-artifact.ts +++ b/src/platforms/android/snapshot-helper-artifact.ts @@ -4,6 +4,10 @@ import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { AppError } from '../../utils/errors.ts'; +import { + readAndroidHelperManifestInteger, + readAndroidHelperManifestLiteral, +} from './instrumentation-helper.ts'; import { ANDROID_SNAPSHOT_HELPER_NAME, ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, @@ -149,6 +153,18 @@ export function parseAndroidSnapshotHelperManifest(value: unknown): AndroidSnaps }; } +function readNumber(value: unknown, field: string): number { + return readAndroidHelperManifestInteger(value, field, 'snapshot helper'); +} + +function readLiteral( + value: unknown, + field: string, + expected: Value, +): Value { + return readAndroidHelperManifestLiteral(value, field, expected, 'snapshot helper'); +} + export function readAndroidSnapshotHelperInstallOptions( manifest: AndroidSnapshotHelperManifest, ): AndroidSnapshotHelperInstallOptions { @@ -283,30 +299,6 @@ function readOptionalNullableString(value: unknown, field: string): string | nul return readString(value, field); } -function readNumber(value: unknown, field: string): number { - if (typeof value !== 'number' || !Number.isInteger(value)) { - throw new AppError( - 'INVALID_ARGS', - `Android snapshot helper manifest ${field} must be an integer.`, - ); - } - return value; -} - -function readLiteral( - value: unknown, - field: string, - expected: Value, -): Value { - if (value !== expected) { - throw new AppError( - 'INVALID_ARGS', - `Android snapshot helper manifest ${field} must be "${expected}".`, - ); - } - return expected; -} - function readStringArray(value: unknown, field: string): string[] { if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { throw new AppError( diff --git a/src/platforms/android/snapshot-helper-capture.ts b/src/platforms/android/snapshot-helper-capture.ts index 874f932f8..551f59134 100644 --- a/src/platforms/android/snapshot-helper-capture.ts +++ b/src/platforms/android/snapshot-helper-capture.ts @@ -1,5 +1,10 @@ import { AppError } from '../../utils/errors.ts'; import type { SnapshotOptions } from '../../utils/snapshot.ts'; +import { + parseInstrumentationRecords, + readInstrumentationResultBoolean, + readInstrumentationResultNumber, +} from './instrumentation-helper.ts'; import { parseUiHierarchy } from './ui-hierarchy.ts'; import { ANDROID_SNAPSHOT_HELPER_COMMAND_OVERHEAD_MS, @@ -22,13 +27,6 @@ type AndroidSnapshotHelperChunk = { payloadBase64: string; }; -type AndroidInstrumentationRecordState = { - status: Array>; - results: Array>; - currentStatus: Record | null; - currentResult: Record | null; -}; - export type AndroidSnapshotHelperResolvedCaptureOptions = { waitForIdleTimeoutMs: number; waitForIdleQuietMs: number; @@ -429,101 +427,10 @@ function readOptionalCaptureMode( return value === 'interactive-windows' || value === 'active-window' ? value : undefined; } -function parseInstrumentationRecords(output: string): { - status: Array>; - results: Array>; -} { - const state: AndroidInstrumentationRecordState = { - status: [], - results: [], - currentStatus: null, - currentResult: null, - }; - - for (const line of output.split(/\r?\n/)) { - readInstrumentationRecordLine(line, state); - } - flushInstrumentationRecords(state); - return { status: state.status, results: state.results }; -} - -function readInstrumentationRecordLine( - line: string, - state: AndroidInstrumentationRecordState, -): void { - if (line.startsWith('INSTRUMENTATION_STATUS: ')) { - state.currentStatus ??= {}; - readKeyValue(line.slice('INSTRUMENTATION_STATUS: '.length), state.currentStatus); - return; - } - if (line.startsWith('INSTRUMENTATION_STATUS_CODE: ')) { - flushStatusRecord(state); - return; - } - if (line.startsWith('INSTRUMENTATION_RESULT: ')) { - state.currentResult ??= {}; - readKeyValue(line.slice('INSTRUMENTATION_RESULT: '.length), state.currentResult); - return; - } - if (line.startsWith('INSTRUMENTATION_CODE: ')) { - flushResultRecord(state); - } -} - -function flushInstrumentationRecords(state: AndroidInstrumentationRecordState): void { - flushStatusRecord(state); - flushResultRecord(state); -} - -function flushStatusRecord(state: { - status: Array>; - currentStatus: Record | null; -}): void { - if (state.currentStatus) { - state.status.push(state.currentStatus); - state.currentStatus = null; - } -} - -function flushResultRecord(state: { - results: Array>; - currentResult: Record | null; -}): void { - if (state.currentResult) { - state.results.push(state.currentResult); - state.currentResult = null; - } -} - -function readKeyValue(line: string, target: Record): void { - const separator = line.indexOf('='); - if (separator < 0) { - return; - } - target[line.slice(0, separator)] = line.slice(separator + 1); -} - -export function readAndroidSnapshotHelperMetadataNumber( - value: string | undefined, -): number | undefined { - if (value === undefined) { - return undefined; - } - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -export function readAndroidSnapshotHelperMetadataBoolean( - value: string | undefined, -): boolean | undefined { - if (value === 'true') { - return true; - } - if (value === 'false') { - return false; - } - return undefined; -} +export { + readInstrumentationResultNumber as readAndroidSnapshotHelperMetadataNumber, + readInstrumentationResultBoolean as readAndroidSnapshotHelperMetadataBoolean, +}; -const readOptionalNumber = readAndroidSnapshotHelperMetadataNumber; -const readOptionalBoolean = readAndroidSnapshotHelperMetadataBoolean; +const readOptionalNumber = readInstrumentationResultNumber; +const readOptionalBoolean = readInstrumentationResultBoolean;