agemu 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { CliError } from '../core/errors.js';
5
+ import { redact } from '../core/redact.js';
6
+ import { listDevices, resolveDevice } from '../native/simctl.js';
7
+ import { runProcess } from '../process/run-process.js';
8
+ const bundledRunner = fileURLToPath(new URL('../../runner/AgentRunner.xcodeproj', import.meta.url));
9
+ function validatePlan(value) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ throw new CliError('UI_VALIDATION_FAILED', 'The UI plan must be an object');
12
+ const plan = value;
13
+ if (plan.version !== 1 || !Array.isArray(plan.actions) || plan.actions.length === 0) {
14
+ throw new CliError('UI_VALIDATION_FAILED', 'The UI plan requires version 1 and at least one action');
15
+ }
16
+ return value;
17
+ }
18
+ async function findXctestrun(directory) {
19
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
20
+ const candidate = path.join(directory, entry.name);
21
+ if (entry.isFile() && entry.name.endsWith('.xctestrun'))
22
+ return candidate;
23
+ if (entry.isDirectory()) {
24
+ const nested = await findXctestrun(candidate);
25
+ if (nested)
26
+ return nested;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+ export function injectEnvironment(value, environment) {
32
+ if (!value || typeof value !== 'object')
33
+ return 0;
34
+ let count = 0;
35
+ if (Array.isArray(value)) {
36
+ for (const item of value)
37
+ count += injectEnvironment(item, environment);
38
+ return count;
39
+ }
40
+ const record = value;
41
+ if (typeof record.TestBundlePath === 'string') {
42
+ record.EnvironmentVariables = { ...record.EnvironmentVariables, ...environment };
43
+ count += 1;
44
+ }
45
+ for (const child of Object.values(record))
46
+ count += injectEnvironment(child, environment);
47
+ return count;
48
+ }
49
+ async function checked(run, executable, args, message) {
50
+ const result = await run(executable, args);
51
+ if (result.exitCode !== 0)
52
+ throw new CliError('UI_DELIVERY_FAILED', message, { exitCode: result.exitCode, stderr: result.stderr });
53
+ return result;
54
+ }
55
+ export async function buildUiRunner(config, dependencies = {}, rebuild = false) {
56
+ const run = dependencies.run ?? runProcess;
57
+ const udid = await (dependencies.resolveUdid?.(config)
58
+ ?? (config.simulator.udid || listDevices().then(devices => resolveDevice(devices, config.simulator).udid)));
59
+ const derivedData = path.join(config.root, '.agemu', 'RunnerDerivedData');
60
+ const project = dependencies.runnerProject ?? bundledRunner;
61
+ let manifest = rebuild ? undefined : await findXctestrun(derivedData).catch(() => undefined);
62
+ const cached = Boolean(manifest);
63
+ if (!manifest) {
64
+ await checked(run, 'xcodebuild', [
65
+ '-project', project, '-scheme', 'AgentRunner', '-configuration', 'Debug',
66
+ '-destination', `platform=iOS Simulator,id=${udid}`, '-derivedDataPath', derivedData, 'build-for-testing',
67
+ ], 'Unable to build the XCTest UI runner');
68
+ manifest = await findXctestrun(derivedData);
69
+ }
70
+ if (!manifest)
71
+ throw new CliError('BUILD_FAILED', 'xcodebuild did not produce an .xctestrun file');
72
+ return { udid, derivedData, manifest, cached };
73
+ }
74
+ export async function runUiPlan(config, planFile, dependencies = {}) {
75
+ const run = dependencies.run ?? runProcess;
76
+ let value;
77
+ try {
78
+ value = JSON.parse(await readFile(planFile, 'utf8'));
79
+ }
80
+ catch (error) {
81
+ throw new CliError('UI_VALIDATION_FAILED', `Cannot read UI plan: ${error instanceof Error ? error.message : String(error)}`);
82
+ }
83
+ const plan = validatePlan(value);
84
+ const built = await buildUiRunner(config, dependencies);
85
+ const now = dependencies.now?.() ?? new Date();
86
+ const directory = path.join(config.root, '.agemu', 'runs', now.toISOString().replaceAll(':', '-'));
87
+ await mkdir(directory, { recursive: true });
88
+ const json = await checked(run, 'plutil', ['-convert', 'json', '-o', '-', built.manifest], 'Unable to read the XCTest run manifest');
89
+ const manifestValue = JSON.parse(json.stdout);
90
+ const encodedPlan = Buffer.from(JSON.stringify({ ...plan, bundleId: config.bundleId }), 'utf8').toString('base64');
91
+ if (injectEnvironment(manifestValue, { AGEMU_PLAN_BASE64: encodedPlan }) === 0) {
92
+ throw new CliError('BUILD_FAILED', 'The XCTest run manifest contains no test target');
93
+ }
94
+ const manifest = path.join(path.dirname(built.manifest), `AgentRunner-${process.pid}-${Date.now()}.xctestrun`);
95
+ await writeFile(manifest, JSON.stringify(manifestValue), { mode: 0o600 });
96
+ await checked(run, 'plutil', ['-convert', 'xml1', manifest], 'Unable to write the XCTest run manifest');
97
+ const resultBundle = path.join(directory, 'AgentRunner.xcresult');
98
+ const result = await run('xcodebuild', [
99
+ 'test-without-building', '-xctestrun', manifest, '-destination', `platform=iOS Simulator,id=${built.udid}`,
100
+ '-resultBundlePath', resultBundle,
101
+ ]).finally(() => unlink(manifest).catch(() => undefined));
102
+ const transcript = path.join(directory, 'xcodebuild.log');
103
+ await writeFile(transcript, redact(`${result.stdout}${result.stderr}`, config.redactions ?? []), { mode: 0o600 });
104
+ if (result.exitCode !== 0) {
105
+ throw new CliError('UI_DELIVERY_FAILED', 'The XCTest UI plan failed', {
106
+ exitCode: result.exitCode, resultBundle: path.relative(config.root, resultBundle), transcript: path.relative(config.root, transcript),
107
+ });
108
+ }
109
+ const marker = result.stdout.split(/\r?\n/).find(line => line.includes('AGEMU_RESULT:'));
110
+ const runnerResult = marker ? JSON.parse(Buffer.from(marker.slice(marker.indexOf('AGEMU_RESULT:') + 13), 'base64').toString('utf8')) : undefined;
111
+ return {
112
+ run: path.relative(config.root, directory), udid: built.udid, bundleId: redact(config.bundleId, config.redactions ?? []),
113
+ actions: plan.actions.length, runnerResult, resultBundle: path.relative(config.root, resultBundle), transcript: path.relative(config.root, transcript),
114
+ };
115
+ }
@@ -0,0 +1,71 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { CliError } from '../core/errors.js';
4
+ const configKeys = new Set(['version', 'project', 'workspace', 'scheme', 'configuration', 'bundleId', 'simulator', 'redactions']);
5
+ const simulatorKeys = new Set(['udid', 'name', 'runtime']);
6
+ function isRecord(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ function validate(value) {
10
+ if (!isRecord(value))
11
+ return [{ path: '$', message: 'must be an object' }];
12
+ const issues = [];
13
+ for (const key of Object.keys(value))
14
+ if (!configKeys.has(key))
15
+ issues.push({ path: key, message: 'is not allowed' });
16
+ if (value.version !== 1)
17
+ issues.push({ path: 'version', message: 'must be 1' });
18
+ const project = value.project;
19
+ const workspace = value.workspace;
20
+ if (project !== undefined && (typeof project !== 'string' || !project))
21
+ issues.push({ path: 'project', message: 'must be a non-empty string' });
22
+ if (workspace !== undefined && (typeof workspace !== 'string' || !workspace))
23
+ issues.push({ path: 'workspace', message: 'must be a non-empty string' });
24
+ if (Number(typeof project === 'string' && project.length > 0) + Number(typeof workspace === 'string' && workspace.length > 0) !== 1) {
25
+ issues.push({ path: 'project', message: 'exactly one of project or workspace is required' });
26
+ issues.push({ path: 'workspace', message: 'exactly one of project or workspace is required' });
27
+ }
28
+ for (const key of ['scheme', 'configuration', 'bundleId']) {
29
+ if (typeof value[key] !== 'string' || !value[key])
30
+ issues.push({ path: key, message: 'must be a non-empty string' });
31
+ }
32
+ if (!isRecord(value.simulator)) {
33
+ issues.push({ path: 'simulator', message: 'must be an object' });
34
+ }
35
+ else {
36
+ for (const key of Object.keys(value.simulator))
37
+ if (!simulatorKeys.has(key))
38
+ issues.push({ path: `simulator.${key}`, message: 'is not allowed' });
39
+ for (const key of ['udid', 'name', 'runtime']) {
40
+ if (value.simulator[key] !== undefined && (typeof value.simulator[key] !== 'string' || !value.simulator[key])) {
41
+ issues.push({ path: `simulator.${key}`, message: 'must be a non-empty string' });
42
+ }
43
+ }
44
+ if (typeof value.simulator.udid !== 'string' && typeof value.simulator.name !== 'string') {
45
+ issues.push({ path: 'simulator', message: 'requires udid or name' });
46
+ }
47
+ }
48
+ if (value.redactions !== undefined && (!Array.isArray(value.redactions) || value.redactions.some((item) => typeof item !== 'string'))) {
49
+ issues.push({ path: 'redactions', message: 'must be an array of strings' });
50
+ }
51
+ return issues;
52
+ }
53
+ export async function loadConfig(root = process.cwd()) {
54
+ const file = path.join(root, '.agemu.json');
55
+ let value;
56
+ try {
57
+ value = JSON.parse(await readFile(file, 'utf8'));
58
+ }
59
+ catch (error) {
60
+ throw new CliError('CONFIG_INVALID', `Cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`);
61
+ }
62
+ const issues = validate(value);
63
+ if (issues.length > 0)
64
+ throw new CliError('CONFIG_INVALID', 'Invalid .agemu.json', { issues });
65
+ const config = value;
66
+ return {
67
+ ...config,
68
+ ...(config.project ? { project: path.resolve(root, config.project) } : { workspace: path.resolve(root, config.workspace) }),
69
+ root: path.resolve(root),
70
+ };
71
+ }
@@ -0,0 +1,10 @@
1
+ export class CliError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, message, details) {
5
+ super(message);
6
+ this.code = code;
7
+ this.details = details;
8
+ this.name = 'CliError';
9
+ }
10
+ }
@@ -0,0 +1,18 @@
1
+ import { CliError } from './errors.js';
2
+ export function writeResult(result, pretty) {
3
+ process.stdout.write(`${JSON.stringify(result, null, pretty ? 2 : 0)}\n`);
4
+ }
5
+ export function errorResult(error, debug) {
6
+ const normalized = error instanceof CliError
7
+ ? error
8
+ : new CliError('PROCESS_FAILED', error instanceof Error ? error.message : String(error));
9
+ return {
10
+ ok: false,
11
+ error: {
12
+ code: normalized.code,
13
+ message: normalized.message,
14
+ ...(normalized.details ? { details: normalized.details } : {}),
15
+ ...(debug && normalized.stack ? { stack: normalized.stack } : {}),
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,4 @@
1
+ const replacement = '[REDACTED]';
2
+ export function redact(value, secrets = []) {
3
+ return secrets.filter(Boolean).reduce((text, secret) => text.replaceAll(secret, replacement), value);
4
+ }
@@ -0,0 +1,91 @@
1
+ import { access, stat } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadConfig } from '../config/config.js';
5
+ import { listDevices, resolveDevice } from '../native/simctl.js';
6
+ import { runProcess } from '../process/run-process.js';
7
+ const message = (error) => error instanceof Error ? error.message : String(error);
8
+ const passed = (result, fallback) => result.exitCode === 0
9
+ ? { ok: true, message: 'available' }
10
+ : { ok: false, message: result.stderr || fallback };
11
+ export async function doctor(dependencies = {}) {
12
+ const root = dependencies.root ?? process.cwd();
13
+ const run = dependencies.run ?? runProcess;
14
+ const readConfig = dependencies.loadConfig ?? loadConfig;
15
+ const devices = dependencies.listDevices ?? listDevices;
16
+ const selectDevice = dependencies.resolveDevice ?? resolveDevice;
17
+ const canWrite = dependencies.canWrite ?? (async (directory) => {
18
+ const stateDirectory = path.join(directory, '.agemu');
19
+ try {
20
+ const metadata = await stat(stateDirectory);
21
+ if (!metadata.isDirectory())
22
+ throw new Error(`${stateDirectory} must be a directory`);
23
+ await access(stateDirectory, constants.W_OK | constants.X_OK);
24
+ }
25
+ catch (error) {
26
+ if (error.code !== 'ENOENT')
27
+ throw error;
28
+ await access(directory, constants.W_OK | constants.X_OK);
29
+ }
30
+ });
31
+ const checks = {};
32
+ checks.node = Number((dependencies.nodeVersion ?? process.versions.node).split('.')[0]) >= 24
33
+ ? { ok: true, message: dependencies.nodeVersion ?? process.version }
34
+ : { ok: false, message: `Node.js ${dependencies.nodeVersion ?? process.version} is below 24` };
35
+ for (const [name, executable, args] of [
36
+ ['xcode', 'xcodebuild', ['-version']],
37
+ ['simctl', 'xcrun', ['simctl', 'help']],
38
+ ]) {
39
+ try {
40
+ checks[name] = passed(await run(executable, [...args]), `${executable} failed`);
41
+ }
42
+ catch (error) {
43
+ checks[name] = { ok: false, message: message(error) };
44
+ }
45
+ }
46
+ let config;
47
+ try {
48
+ config = await readConfig(root);
49
+ checks.config = { ok: true, message: 'valid' };
50
+ }
51
+ catch (error) {
52
+ checks.config = { ok: false, message: message(error) };
53
+ }
54
+ if (!config) {
55
+ for (const name of ['project', 'scheme', 'simulator'])
56
+ checks[name] = { ok: false, message: 'configuration is unavailable' };
57
+ }
58
+ else {
59
+ const source = config.project ?? config.workspace;
60
+ try {
61
+ await access(source, constants.F_OK);
62
+ checks.project = { ok: true, message: path.relative(root, source) || '.' };
63
+ }
64
+ catch (error) {
65
+ checks.project = { ok: false, message: message(error) };
66
+ }
67
+ try {
68
+ const arguments_ = [config.project ? '-project' : '-workspace', source, '-scheme', config.scheme, '-configuration', config.configuration, '-showBuildSettings'];
69
+ checks.scheme = passed(await run('xcodebuild', arguments_), `Scheme ${config.scheme} failed validation`);
70
+ }
71
+ catch (error) {
72
+ checks.scheme = { ok: false, message: message(error) };
73
+ }
74
+ try {
75
+ selectDevice(await devices(), config.simulator);
76
+ checks.simulator = { ok: true, message: 'resolved' };
77
+ }
78
+ catch (error) {
79
+ checks.simulator = { ok: false, message: message(error) };
80
+ }
81
+ }
82
+ try {
83
+ await canWrite(root);
84
+ checks.stateDirectory = { ok: true, message: '.agemu/ can be created' };
85
+ }
86
+ catch (error) {
87
+ checks.stateDirectory = { ok: false, message: message(error) };
88
+ }
89
+ const ready = Object.values(checks).every((check) => check.ok);
90
+ return { ready, checks };
91
+ }
@@ -0,0 +1,104 @@
1
+ import { CliError } from '../core/errors.js';
2
+ import { runProcess } from '../process/run-process.js';
3
+ function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ function runtimeName(identifier) {
7
+ return identifier.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, '');
8
+ }
9
+ function isAvailable(device) {
10
+ return device.isAvailable !== false && !String(device.availability ?? '').toLowerCase().includes('unavailable');
11
+ }
12
+ function normalizedRuntime(value) {
13
+ return runtimeName(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
14
+ }
15
+ export function parseDevices(json) {
16
+ if (!isRecord(json) || !isRecord(json.devices)) {
17
+ throw new CliError('PROCESS_FAILED', 'simctl returned invalid device JSON');
18
+ }
19
+ const devices = [];
20
+ for (const [runtime, entries] of Object.entries(json.devices)) {
21
+ if (!Array.isArray(entries))
22
+ continue;
23
+ for (const value of entries) {
24
+ if (!isRecord(value))
25
+ continue;
26
+ const device = value;
27
+ if (typeof device.udid !== 'string' || typeof device.name !== 'string' || typeof device.state !== 'string')
28
+ continue;
29
+ devices.push({
30
+ udid: device.udid,
31
+ name: device.name,
32
+ runtime: runtimeName(runtime),
33
+ state: device.state,
34
+ isAvailable: isAvailable(device),
35
+ });
36
+ }
37
+ }
38
+ return devices;
39
+ }
40
+ export async function simctl(args, options) {
41
+ return runProcess('xcrun', ['simctl', ...args], options);
42
+ }
43
+ function processFailure(args, result) {
44
+ return new CliError('PROCESS_FAILED', result.stderr.trim() || `simctl ${args[0] ?? 'command'} failed`, {
45
+ command: ['xcrun', 'simctl', ...args],
46
+ exitCode: result.exitCode,
47
+ signal: result.signal,
48
+ });
49
+ }
50
+ async function runChecked(runner, args, options) {
51
+ const result = await runner(args, options);
52
+ if (result.exitCode !== 0)
53
+ throw processFailure(args, result);
54
+ return result;
55
+ }
56
+ export async function listDevices(runner = simctl) {
57
+ const result = await runChecked(runner, ['list', '--json']);
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(result.stdout);
61
+ }
62
+ catch {
63
+ throw new CliError('PROCESS_FAILED', 'simctl returned invalid JSON');
64
+ }
65
+ return parseDevices(parsed).filter((device) => device.isAvailable && device.runtime.startsWith('iOS-'));
66
+ }
67
+ export function resolveDevice(devices, selector) {
68
+ const available = devices.filter((device) => device.isAvailable);
69
+ const matches = selector.udid
70
+ ? available.filter((device) => device.udid === selector.udid)
71
+ : available.filter((device) => device.name === selector.name && (!selector.runtime || normalizedRuntime(device.runtime) === normalizedRuntime(selector.runtime)));
72
+ if (matches.length === 0) {
73
+ throw new CliError('SIMULATOR_NOT_FOUND', 'Simulator selector matched no available device', { selector });
74
+ }
75
+ if (matches.length > 1) {
76
+ throw new CliError('SIMULATOR_AMBIGUOUS', 'Simulator selector matched multiple devices', {
77
+ selector,
78
+ candidates: matches,
79
+ });
80
+ }
81
+ return matches[0];
82
+ }
83
+ async function currentDevice(udid, runner) {
84
+ return (await listDevices(runner)).find((device) => device.udid === udid);
85
+ }
86
+ export async function bootDevice(device, runner = simctl) {
87
+ if (device.state !== 'Booted') {
88
+ const result = await runner(['boot', device.udid]);
89
+ if (result.exitCode !== 0 && (await currentDevice(device.udid, runner))?.state !== 'Booted') {
90
+ throw processFailure(['boot', device.udid], result);
91
+ }
92
+ }
93
+ await runChecked(runner, ['bootstatus', device.udid, '-b'], { timeoutMs: 120_000 });
94
+ return { ...device, state: 'Booted' };
95
+ }
96
+ export async function shutdownDevice(device, runner = simctl) {
97
+ if (device.state === 'Shutdown')
98
+ return device;
99
+ const result = await runner(['shutdown', device.udid]);
100
+ if (result.exitCode !== 0 && (await currentDevice(device.udid, runner))?.state !== 'Shutdown') {
101
+ throw processFailure(['shutdown', device.udid], result);
102
+ }
103
+ return { ...device, state: 'Shutdown' };
104
+ }
@@ -0,0 +1,49 @@
1
+ import path from 'node:path';
2
+ export function buildArguments(config, udid, action) {
3
+ const source = config.workspace
4
+ ? ['-workspace', config.workspace]
5
+ : ['-project', config.project];
6
+ return [
7
+ ...source,
8
+ '-scheme', config.scheme,
9
+ '-configuration', config.configuration,
10
+ '-destination', `platform=iOS Simulator,id=${udid}`,
11
+ '-derivedDataPath', path.join(config.root, '.agemu', 'DerivedData'),
12
+ ...(action === 'build' ? ['build'] : ['-showBuildSettings']),
13
+ ];
14
+ }
15
+ export function parseBuildProducts(output) {
16
+ const products = [];
17
+ let target = '';
18
+ let settings = {};
19
+ const add = () => {
20
+ const directory = settings.TARGET_BUILD_DIR;
21
+ const wrapper = settings.WRAPPER_NAME;
22
+ const bundleId = settings.PRODUCT_BUNDLE_IDENTIFIER;
23
+ const executableName = settings.EXECUTABLE_NAME;
24
+ if (target && directory && wrapper && bundleId && executableName) {
25
+ products.push({ target, appPath: path.join(directory, wrapper), bundleId, executableName });
26
+ }
27
+ settings = {};
28
+ };
29
+ for (const line of output.split(/\r?\n/)) {
30
+ const heading = line.match(/^Build settings for action .* and target (.+):$/);
31
+ if (heading) {
32
+ add();
33
+ target = heading[1];
34
+ continue;
35
+ }
36
+ const setting = line.match(/^\s+([A-Z0-9_]+)\s*=\s*(.*)$/);
37
+ if (setting)
38
+ settings[setting[1]] = setting[2].trim();
39
+ }
40
+ add();
41
+ return products;
42
+ }
43
+ export function selectBuildProduct(output, bundleId) {
44
+ const matches = parseBuildProducts(output).filter((product) => product.bundleId === bundleId && product.appPath.endsWith('.app'));
45
+ if (matches.length !== 1) {
46
+ throw new Error(`Expected one app product for ${bundleId}, found ${matches.length}`);
47
+ }
48
+ return matches[0];
49
+ }
@@ -0,0 +1,62 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { CliError } from '../core/errors.js';
3
+ export function runProcess(executable, args, options = {}) {
4
+ return new Promise((resolve, reject) => {
5
+ const started = Date.now();
6
+ const startedAt = new Date(started).toISOString();
7
+ const child = spawn(executable, args, { cwd: options.cwd, env: options.env, shell: false });
8
+ let stdout = '';
9
+ let stderr = '';
10
+ let settled = false;
11
+ let timer;
12
+ let killTimer;
13
+ let termination;
14
+ const finish = (fn) => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ if (timer)
19
+ clearTimeout(timer);
20
+ if (killTimer)
21
+ clearTimeout(killTimer);
22
+ options.signal?.removeEventListener('abort', abort);
23
+ fn();
24
+ };
25
+ const result = (exitCode, signal) => ({
26
+ stdout,
27
+ stderr,
28
+ exitCode,
29
+ signal,
30
+ startedAt,
31
+ durationMs: Date.now() - started,
32
+ });
33
+ const terminate = (error) => {
34
+ if (termination || settled)
35
+ return;
36
+ termination = error;
37
+ child.kill('SIGTERM');
38
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 1_000);
39
+ };
40
+ const abort = () => terminate(new CliError('PROCESS_TIMEOUT', 'Process cancelled'));
41
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
42
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
43
+ child.once('error', (error) => finish(() => reject(new CliError(error.code === 'ENOENT' ? 'TOOL_NOT_FOUND' : 'PROCESS_FAILED', error.message))));
44
+ child.once('close', (exitCode, signal) => finish(() => {
45
+ if (termination) {
46
+ reject(new CliError(termination.code, termination.message, { ...termination.details, result: result(exitCode, signal) }));
47
+ }
48
+ else {
49
+ resolve(result(exitCode, signal));
50
+ }
51
+ }));
52
+ if (options.signal) {
53
+ if (options.signal.aborted)
54
+ abort();
55
+ else
56
+ options.signal.addEventListener('abort', abort, { once: true });
57
+ }
58
+ if (options.timeoutMs !== undefined) {
59
+ timer = setTimeout(() => terminate(new CliError('PROCESS_TIMEOUT', `Process timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
60
+ }
61
+ });
62
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "agemu",
3
+ "version": "0.1.0",
4
+ "description": "Build, run, inspect, and control iOS apps in Simulator from a JSON-first CLI.",
5
+ "license": "MIT",
6
+ "author": "Illia Puzanov",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/zoilorys/agemu.git"
10
+ },
11
+ "homepage": "https://github.com/zoilorys/agemu#readme",
12
+ "bugs": "https://github.com/zoilorys/agemu/issues",
13
+ "keywords": ["ios", "simulator", "xcode", "cli", "testing"],
14
+ "engines": { "node": ">=24" },
15
+ "packageManager": "pnpm@11.24.0",
16
+ "files": ["dist", "runner", "README.md", "LICENSE"],
17
+ "type": "module",
18
+ "bin": { "agemu": "dist/cli/main.js" },
19
+ "scripts": {
20
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
21
+ "build": "pnpm clean && tsc -p tsconfig.json",
22
+ "test": "tsc -p tsconfig.json && vitest run",
23
+ "test:integration:native": "pnpm build && AGEMU_NATIVE=1 vitest run test/integration/native-workflow.test.ts",
24
+ "prepack": "pnpm build"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^24.0.0",
28
+ "typescript": "^5.9.0",
29
+ "vitest": "^3.2.0"
30
+ }
31
+ }
@@ -0,0 +1,82 @@
1
+ import XCTest
2
+
3
+ final class AgentRunner: XCTestCase {
4
+ private struct Plan: Decodable { let bundleId: String; let actions: [Action] }
5
+ private struct Action: Decodable {
6
+ let launch: Launch?
7
+ let tap: Target?
8
+ let type: TypeAction?
9
+ let wait: WaitAction?
10
+ let assertVisible: Target?
11
+ let assertValue: ValueAssertion?
12
+ let screenshot: Screenshot?
13
+ let inspect: Empty?
14
+ }
15
+ private struct Launch: Decodable { let arguments: [String]?; let environment: [String: String]? }
16
+ private struct Target: Decodable { let identifier: String?; let label: String?; let x: Double?; let y: Double? }
17
+ private struct TypeAction: Decodable { let identifier: String?; let label: String?; let text: String }
18
+ private struct WaitAction: Decodable { let identifier: String?; let label: String?; let timeout: Double? }
19
+ private struct ValueAssertion: Decodable { let identifier: String?; let label: String?; let value: String }
20
+ private struct Screenshot: Decodable { let name: String? }
21
+ private struct Empty: Decodable {}
22
+ private struct Result: Encodable { let completed: Int; let bundleId: String; let trees: [String] }
23
+
24
+ @MainActor
25
+ func testPlan() throws {
26
+ let encoded = try XCTUnwrap(ProcessInfo.processInfo.environment["AGEMU_PLAN_BASE64"])
27
+ let data = try XCTUnwrap(Data(base64Encoded: encoded))
28
+ let plan = try JSONDecoder().decode(Plan.self, from: data)
29
+ let app = XCUIApplication(bundleIdentifier: plan.bundleId)
30
+ var trees: [String] = []
31
+
32
+ for (index, action) in plan.actions.enumerated() {
33
+ if let launch = action.launch {
34
+ app.launchArguments = launch.arguments ?? []
35
+ app.launchEnvironment = launch.environment ?? [:]
36
+ app.launch()
37
+ } else if let target = action.tap {
38
+ try tap(target, in: app)
39
+ } else if let type = action.type {
40
+ let element = try element(Target(identifier: type.identifier, label: type.label, x: nil, y: nil), in: app)
41
+ element.tap()
42
+ element.typeText(type.text)
43
+ } else if let wait = action.wait {
44
+ let candidate = try element(Target(identifier: wait.identifier, label: wait.label, x: nil, y: nil), in: app)
45
+ XCTAssertTrue(candidate.waitForExistence(timeout: wait.timeout ?? 5), "Action \(index): element did not appear")
46
+ } else if let target = action.assertVisible {
47
+ XCTAssertTrue(try element(target, in: app).exists, "Action \(index): element is not visible")
48
+ } else if let assertion = action.assertValue {
49
+ let candidate = try element(Target(identifier: assertion.identifier, label: assertion.label, x: nil, y: nil), in: app)
50
+ XCTAssertEqual(candidate.value as? String, assertion.value, "Action \(index): element value does not match")
51
+ } else if let shot = action.screenshot {
52
+ let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
53
+ attachment.name = shot.name ?? "action-\(index)"
54
+ attachment.lifetime = .keepAlways
55
+ add(attachment)
56
+ } else if action.inspect != nil {
57
+ trees.append(app.debugDescription)
58
+ } else {
59
+ XCTFail("Action \(index) has no supported operation")
60
+ }
61
+ }
62
+
63
+ let output = try JSONEncoder().encode(Result(completed: plan.actions.count, bundleId: plan.bundleId, trees: trees))
64
+ print("AGEMU_RESULT:\(output.base64EncodedString())")
65
+ }
66
+
67
+ @MainActor
68
+ private func element(_ target: Target, in app: XCUIApplication) throws -> XCUIElement {
69
+ if let identifier = target.identifier { return app.descendants(matching: .any)[identifier] }
70
+ if let label = target.label { return app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch }
71
+ throw NSError(domain: "AgentRunner", code: 1, userInfo: [NSLocalizedDescriptionKey: "Element target requires identifier or label"])
72
+ }
73
+
74
+ @MainActor
75
+ private func tap(_ target: Target, in app: XCUIApplication) throws {
76
+ if let x = target.x, let y = target.y {
77
+ app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x, dy: y)).tap()
78
+ } else {
79
+ try element(target, in: app).tap()
80
+ }
81
+ }
82
+ }