Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/platforms/android/instrumentation-helper.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>;
results: Array<Record<string, string>>;
currentStatus: Record<string, string> | null;
currentResult: Record<string, string> | null;
};

export function parseInstrumentationRecords(output: string): {
status: Array<Record<string, string>>;
results: Array<Record<string, string>>;
} {
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<Record<string, string>>;
currentStatus: Record<string, string> | null;
}): void {
if (state.currentStatus) {
state.status.push(state.currentStatus);
state.currentStatus = null;
}
}

function flushResultRecord(state: {
results: Array<Record<string, string>>;
currentResult: Record<string, string> | null;
}): void {
if (state.currentResult) {
state.results.push(state.currentResult);
state.currentResult = null;
}
}

function readKeyValue(line: string, target: Record<string, string>): 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<const Value extends string>(
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;
}
61 changes: 16 additions & 45 deletions src/platforms/android/multitouch-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -401,7 +407,7 @@ export async function runAndroidMultiTouchHelperGesture(options: {
}

export function parseAndroidMultiTouchHelperOutput(output: string): Record<string, unknown> {
const finalResult = parseInstrumentationResults(output).find(
const finalResult = parseInstrumentationRecords(output).results.find(
(record) => record.agentDeviceProtocol === ANDROID_MULTITOUCH_HELPER_PROTOCOL,
);
if (!finalResult) {
Expand All @@ -423,8 +429,8 @@ export function parseAndroidMultiTouchHelperOutput(output: string): Record<strin
return {
kind: finalResult.kind,
helperApiVersion: finalResult.helperApiVersion,
injectedEvents: readOptionalNumber(finalResult.injectedEvents),
elapsedMs: readOptionalNumber(finalResult.elapsedMs),
injectedEvents: readInstrumentationResultNumber(finalResult.injectedEvents),
elapsedMs: readInstrumentationResultNumber(finalResult.elapsedMs),
};
}

Expand All @@ -434,33 +440,6 @@ function readHelperErrorMessage(finalResult: Record<string, string>): string {
: finalResult.errorType || 'Android multi-touch helper returned an error';
}

function parseInstrumentationResults(output: string): Array<Record<string, string>> {
const results: Array<Record<string, string>> = [];
let current: Record<string, string> | 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<string, string>): 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<AndroidMultiTouchHelperArtifact> {
const version = readVersion();
const helperDir = path.join(findProjectRoot(), 'android-multitouch-helper', 'dist');
Expand Down Expand Up @@ -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<T extends string>(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<const Value extends string>(
value: unknown,
field: string,
expected: Value,
): Value {
return readAndroidHelperManifestLiteral(value, field, expected, 'multi-touch helper');
}

function readSha256(value: unknown): string {
Expand Down
40 changes: 16 additions & 24 deletions src/platforms/android/snapshot-helper-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<const Value extends string>(
value: unknown,
field: string,
expected: Value,
): Value {
return readAndroidHelperManifestLiteral(value, field, expected, 'snapshot helper');
}

export function readAndroidSnapshotHelperInstallOptions(
manifest: AndroidSnapshotHelperManifest,
): AndroidSnapshotHelperInstallOptions {
Expand Down Expand Up @@ -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<const Value extends string>(
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(
Expand Down
Loading
Loading