Skip to content
Open
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
69 changes: 69 additions & 0 deletions src/platforms/ios/__tests__/runner-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,67 @@ test('parseRunnerResponse preserves XCTest recorded failure code and hint', asyn
);
});

test('parseRunnerResponse classifies target app AXRuntime CoreText font crashes from runner log tail', async () => {
const logPath = writeRunnerLogTail(`
Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
0 libobjc.A.dylib objc_retain + 16
1 CoreText CreateFontWithFontURL(__CFURL const*, __CFString const*, __CFString const*) + 512
11 AXRuntime reconstitutedSmuggledCTFontFromDictionary + 192
12 AXRuntime -[NSDictionary(AXPropertyListCoersion) _axRecursivelyReconstitutedRepresentationFromPropertyListWithError:] + 156
`);
const response = new Response(
JSON.stringify({
ok: false,
error: {
code: 'XCTEST_RECORDED_FAILURE',
message:
'XCTest recorded a failure while executing type; the action may not have been performed.',
},
}),
);
const session = { ready: true };

await assert.rejects(
() => parseRunnerResponse(response, session, logPath),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'IOS_TARGET_APP_CRASH');
assert.equal(error.details?.runnerFailureReason, 'target_app_axruntime_coretext_crash');
assert.match(String(error.details?.hint), /AXRuntime read accessibility attributes/);
assert.match(String(error.details?.hint), /latest stable simulator runtime/);
assert.match(String(error.details?.hint), /exact command, selector\/ref/);
return true;
},
);
});

test('parseRunnerResponse keeps ordinary runner failures generic without crash log evidence', async () => {
const logPath = writeRunnerLogTail(
'AGENT_DEVICE_RUNNER_COMMAND_FAILED command=type error=main thread execution timed out',
);
const response = new Response(
JSON.stringify({
ok: false,
error: {
code: 'COMMAND_FAILED',
message: 'main thread execution timed out',
},
}),
);
const session = { ready: true };

await assert.rejects(
() => parseRunnerResponse(response, session, logPath),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.details?.runnerFailureReason, undefined);
assert.equal(error.details?.hint, undefined);
return true;
},
);
});

test('parseRunnerResponse emits diagnostics for runner gesture fallbacks', async () => {
const response = new Response(
JSON.stringify({
Expand All @@ -710,6 +771,14 @@ test('parseRunnerResponse emits diagnostics for runner gesture fallbacks', async
assert.match(diagnostics, /xctest-coordinate-drag/);
});

function writeRunnerLogTail(contents: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-log-'));
onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true }));
const logPath = path.join(dir, 'runner.log');
fs.writeFileSync(logPath, contents);
return logPath;
}

test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => {
const err = new AppError(
'COMMAND_FAILED',
Expand Down
107 changes: 107 additions & 0 deletions src/platforms/ios/runner-failure-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import fs from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import { AppError, type AppErrorCode } from '../../utils/errors.ts';

const RUNNER_LOG_TAIL_BYTES = 64 * 1024;

type RunnerFailureDiagnostic = {
code?: AppErrorCode;
reason: string;
hint: string;
};

const IOS_TARGET_AX_CRASH_HINT =
'The target iOS app appears to have crashed while XCTest/AXRuntime read accessibility attributes. This is usually a simulator/XCTest/runtime or app accessibility payload issue, not a text-entry failure. Reproduce on the latest stable simulator runtime, reinstall the app, and capture the app crash from Console.app or ~/Library/Logs/DiagnosticReports with the exact command, selector/ref, app build, Xcode, and simulator runtime.';

const IOS_TARGET_APP_CRASH_HINT =
'The target iOS app appears to have crashed while the runner was executing the command. Reopen or reinstall the app, retry on a fresh/latest stable simulator runtime, and capture the app crash from Console.app or ~/Library/Logs/DiagnosticReports with the exact command, selector/ref, app build, Xcode, and simulator runtime.';

export async function enrichRunnerFailureFromLog(params: {
error: AppError;
logPath?: string;
}): Promise<AppError> {
const diagnostic = await resolveRunnerFailureDiagnostic(params.logPath);
if (!diagnostic) return params.error;

return new AppError(
diagnostic.code ?? params.error.code,
params.error.message,
{
...(params.error.details ?? {}),
hint:
typeof params.error.details?.hint === 'string'
? `${params.error.details.hint} ${diagnostic.hint}`
: diagnostic.hint,
runnerFailureReason: diagnostic.reason,
},
params.error,
);
}

async function resolveRunnerFailureDiagnostic(
logPath: string | undefined,
): Promise<RunnerFailureDiagnostic | undefined> {
if (!logPath) return undefined;
const tail = await readFileTail(logPath, RUNNER_LOG_TAIL_BYTES);
if (!tail) return undefined;
return classifyRunnerFailureLog(tail);
}

function classifyRunnerFailureLog(logText: string): RunnerFailureDiagnostic | undefined {
const normalized = logText.toLowerCase();
if (isAxRuntimeAccessibilityCrash(normalized)) {
return {
code: 'IOS_TARGET_APP_CRASH',
reason: 'target_app_axruntime_coretext_crash',
hint: IOS_TARGET_AX_CRASH_HINT,
};
}
if (isTargetAppCrash(normalized)) {
return {
code: 'IOS_TARGET_APP_CRASH',
reason: 'target_app_crash',
hint: IOS_TARGET_APP_CRASH_HINT,
};
}
return undefined;
}

function isAxRuntimeAccessibilityCrash(normalized: string): boolean {
return (
normalized.includes('axruntime') &&
normalized.includes('coretext') &&
(normalized.includes('attributesforelement') ||
normalized.includes('axuielementcopymultipleattributevalues') ||
normalized.includes('reconstitutedsmuggledctfontfromdictionary') ||
normalized.includes('reconstitutedsmuggledattributedstringfromdictionary'))
);
}

function isTargetAppCrash(normalized: string): boolean {
return (
normalized.includes('process crashed') ||
normalized.includes('the application under test') ||
normalized.includes('terminated unexpectedly') ||
(normalized.includes('exception type:') && normalized.includes('thread 0 crashed')) ||
(normalized.includes('crashed') && normalized.includes('xctest'))
);
}

async function readFileTail(filePath: string, maxBytes: number): Promise<string | undefined> {
let handle: FileHandle | undefined;
try {
const stat = await fs.stat(filePath);
const start = Math.max(0, stat.size - maxBytes);
const length = stat.size - start;
if (length <= 0) return undefined;

handle = await fs.open(filePath, 'r');
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, start);
return buffer.toString('utf8');
} catch {
return undefined;
} finally {
await handle?.close().catch(() => {});
}
}
20 changes: 12 additions & 8 deletions src/platforms/ios/runner-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
RUNNER_INVALIDATE_WAIT_TIMEOUT_MS,
stopRunnerPrepProcesses,
} from './runner-disposal.ts';
import { enrichRunnerFailureFromLog } from './runner-failure-diagnostics.ts';
import type { RunnerSession } from './runner-session-types.ts';

export type { RunnerSession } from './runner-session-types.ts';
Expand Down Expand Up @@ -693,14 +694,17 @@ export async function parseRunnerResponse(
: 'COMMAND_FAILED';
const errorMessage = typeof json.error?.message === 'string' ? json.error.message : undefined;
const hint = typeof json.error?.hint === 'string' ? json.error.hint : undefined;
throw new AppError(errorCode, errorMessage ?? 'Runner error', {
runner: json,
xcodebuild: {
exitCode: 1,
stdout: '',
stderr: '',
},
hint,
throw await enrichRunnerFailureFromLog({
error: new AppError(errorCode, errorMessage ?? 'Runner error', {
runner: json,
xcodebuild: {
exitCode: 1,
stdout: '',
stderr: '',
},
hint,
logPath,
}),
logPath,
});
}
Expand Down
Loading