Skip to content
Draft
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
86 changes: 86 additions & 0 deletions src/adapters/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export type AdapterCommand = {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
};

export type AdapterCommandResult = {
stdout: string;
stderr: string;
exitCode: number | null;
};

export type AdapterCommandRunner = (command: AdapterCommand) => Promise<AdapterCommandResult>;

type HostProcessLike = {
getBuiltinModule?(name: string): unknown;
env?: Record<string, string | undefined>;
};

type NodeChildProcessLike = {
execFile(
command: string,
args: string[],
options: { cwd?: string; env?: Record<string, string | undefined> },
callback: (error: unknown, stdout: string | Buffer, stderr: string | Buffer) => void,
): void;
};

function nodeBuiltin<T>(name: string): T | undefined {
return (
globalThis as typeof globalThis & { process?: HostProcessLike }
).process?.getBuiltinModule?.(name) as T | undefined;
}

export function createNodeCommandRunner(): AdapterCommandRunner {
const childProcess = nodeBuiltin<NodeChildProcessLike>('child_process');
if (childProcess === undefined) {
throw new Error('pipeline adapter commands require a Node-compatible child_process host.');
}
return ({ command, args = [], cwd, env }) =>
new Promise((resolve, reject) => {
childProcess.execFile(
command,
args,
{
cwd,
env: { ...(globalThis as { process?: HostProcessLike }).process?.env, ...env },
},
(error, stdout, stderr) => {
const result = {
stdout: stdout.toString(),
stderr: stderr.toString(),
exitCode:
typeof error === 'object' &&
error !== null &&
typeof (error as { code?: unknown }).code === 'number'
? (error as { code: number }).code
: error === null
? 0
: null,
};
if (error !== null) {
reject(
Object.assign(
new Error(result.stderr || result.stdout || String(error)),
result,
),
);
return;
}
resolve(result);
},
);
});
}

export function parseJsonCommandOutput<T>(result: AdapterCommandResult, context: string): T {
try {
return JSON.parse(result.stdout) as T;
} catch (error) {
throw new Error(
`${context} returned invalid JSON: ${error instanceof Error ? error.message : error}`,
);
}
}
101 changes: 101 additions & 0 deletions src/adapters/ios-xctest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import path from 'pathe';
import type {
PipelineAccessibilityNode,
PipelineAction,
PipelineDriverSession,
PipelineOpenAdapter,
PipelineOpenTarget,
} from '../pipeline-open.ts';
import {
createNodeCommandRunner,
parseJsonCommandOutput,
type AdapterCommandRunner,
} from './command.ts';

export type IOSXCTestAdapterOptions = {
targetApp?: string;
bundleId?: string;
device?: string;
xctestBridgeCommand?: string;
runner?: AdapterCommandRunner;
};

function stringValue(
target: PipelineOpenTarget,
key: string,
fallback?: string,
): string | undefined {
const value = target[key];
return typeof value === 'string' ? value : fallback;
}

function screenshotPath(runDir: string, sessionId: string, label: string): string {
const safeLabel =
label.replaceAll(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '') || 'screenshot';
return path.join(runDir, `${sessionId}-${safeLabel}.png`);
}

function bridgePayload(
target: PipelineOpenTarget,
context: { sessionId: string },
extra = {},
): string {
return JSON.stringify({ target, sessionId: context.sessionId, ...extra });
}

export function iosXCTestAdapter(options: IOSXCTestAdapterOptions = {}): PipelineOpenAdapter {
const runner = options.runner ?? createNodeCommandRunner();
const device = options.device ?? 'booted';
const bridge = options.xctestBridgeCommand;
const runBridge = async <T>(
operation: string,
target: PipelineOpenTarget,
context: { sessionId: string },
extra = {},
): Promise<T> => {
if (bridge === undefined) {
throw new Error(
'iosXCTestAdapter needs xctestBridgeCommand for accessibility and actions.',
);
}
return parseJsonCommandOutput(
await runner({
command: bridge,
args: [operation, bridgePayload(target, context, extra)],
}),
`ios-xctest ${operation}`,
);
};
return {
name: 'ios-xctest',
platform: 'mobile',
supports: (target) =>
options.targetApp === undefined ||
target.app === undefined ||
target.app === options.targetApp,
open: async (target, context): Promise<PipelineDriverSession> => {
const bundleId = stringValue(target, 'bundleId', options.bundleId);
if (bundleId === undefined) {
throw new Error('iosXCTestAdapter needs target.bundleId or options.bundleId.');
}
await runner({ command: 'xcrun', args: ['simctl', 'launch', device, bundleId] });
return {
accessibilityTree: async (): Promise<PipelineAccessibilityNode> =>
runBridge('accessibility', target, context),
action: async (action: PipelineAction) =>
runBridge('action', target, context, { action }),
screenshot: async (label: string) => {
const file = screenshotPath(context.runDir, context.sessionId, label);
await runner({
command: 'xcrun',
args: ['simctl', 'io', device, 'screenshot', file],
});
return { label, path: file };
},
logs: async () => (bridge === undefined ? [] : runBridge('logs', target, context)),
crash: async () =>
bridge === undefined ? null : runBridge('crash', target, context),
};
},
};
}
207 changes: 207 additions & 0 deletions src/adapters/macos-ax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import path from 'pathe';
import type {
PipelineAccessibilityNode,
PipelineAction,
PipelineDriverSession,
PipelineOpenAdapter,
PipelineOpenTarget,
} from '../pipeline-open.ts';
import {
createNodeCommandRunner,
parseJsonCommandOutput,
type AdapterCommandRunner,
} from './command.ts';

export type MacOSAXAdapterOptions = {
targetApp?: string;
app?: string;
appName?: string;
appPath?: string;
bundleId?: string;
xctestBridgeCommand?: string;
maxDepth?: number;
runner?: AdapterCommandRunner;
};

const SNAPSHOT_SCRIPT = `
function text(value) { try { return value() == null ? undefined : String(value()); } catch (_) { return undefined; } }
function childrenOf(element) { try { return element.uiElements(); } catch (_) { return []; } }
function node(element, depth, maxDepth) {
var out = { role: text(element.role) || 'unknown' };
var name = text(element.name);
if (name) out.name = name;
var value = text(element.value);
if (value) out.value = value;
if (depth < maxDepth) out.children = childrenOf(element).map(function (child) { return node(child, depth + 1, maxDepth); });
return out;
}
function run(argv) {
var appName = argv[0];
var maxDepth = Number(argv[1] || '4');
var app = Application('System Events').processes.byName(appName);
return JSON.stringify(node(app, 0, maxDepth));
}
`;

const ACTION_SCRIPT = `
function text(value) { try { return value() == null ? undefined : String(value()); } catch (_) { return undefined; } }
function childrenOf(element) { try { return element.uiElements(); } catch (_) { return []; } }
function matches(element, action) {
if (action.role && text(element.role) !== action.role) return false;
if (action.name && text(element.name) !== action.name) return false;
return Boolean(action.role || action.name);
}
function find(element, action) {
if (matches(element, action)) return element;
var children = childrenOf(element);
for (var i = 0; i < children.length; i++) {
var found = find(children[i], action);
if (found) return found;
}
return null;
}
function run(argv) {
var appName = argv[0];
var action = JSON.parse(argv[1]);
var app = Application('System Events').processes.byName(appName);
var element = find(app, action);
if (!element) throw new Error('No matching accessibility element');
if (action.kind === 'activate' || action.kind === 'click') element.click();
return JSON.stringify({ status: 'passed' });
}
`;

function stringValue(
target: PipelineOpenTarget,
key: string,
fallback?: string,
): string | undefined {
const value = target[key];
return typeof value === 'string' ? value : fallback;
}

function screenshotPath(runDir: string, sessionId: string, label: string): string {
const safeLabel =
label.replaceAll(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '') || 'screenshot';
return path.join(runDir, `${sessionId}-${safeLabel}.png`);
}

function appNameFromPath(appPath: string): string {
return path.basename(appPath).replace(/\.app$/i, '');
}

function bridgePayload(
target: PipelineOpenTarget,
context: { sessionId: string },
extra = {},
): string {
return JSON.stringify({ target, sessionId: context.sessionId, ...extra });
}

const QUIT_SCRIPT = `
function run(argv) {
try { Application(argv[0]).quit(); } catch (_) {}
return JSON.stringify({ status: 'closed' });
}
`;

export function macosAXAdapter(options: MacOSAXAdapterOptions = {}): PipelineOpenAdapter {
const runner = options.runner ?? createNodeCommandRunner();
const bridge = options.xctestBridgeCommand;
return {
name: 'macos-ax',
platform: 'desktop',
supports: (target) =>
options.targetApp === undefined ||
target.app === undefined ||
target.app === options.targetApp,
open: async (target, context): Promise<PipelineDriverSession> => {
const app = options.app ?? stringValue(target, 'app');
const appPath = stringValue(target, 'appPath', options.appPath);
const appName = stringValue(target, 'appName', options.appName);
const bundleId = stringValue(target, 'bundleId', options.bundleId);
const processName =
appName ?? app ?? (appPath === undefined ? bundleId : appNameFromPath(appPath));
if (appPath !== undefined) {
await runner({ command: 'open', args: [appPath] });
} else if (bundleId !== undefined) {
await runner({ command: 'open', args: ['-b', bundleId] });
} else if (app !== undefined) {
await runner({ command: 'open', args: ['-a', app] });
} else {
throw new Error(
'macosAXAdapter needs target.app, target.appPath, or target.bundleId.',
);
}
if (processName === undefined) {
throw new Error('macosAXAdapter needs an app name for accessibility lookup.');
}
return {
accessibilityTree: async (): Promise<PipelineAccessibilityNode> =>
parseJsonCommandOutput(
await runner({
command: 'osascript',
args: [
'-l',
'JavaScript',
'-e',
SNAPSHOT_SCRIPT,
processName,
String(options.maxDepth ?? 4),
],
}),
'macos-ax accessibility snapshot',
),
action: async (action: PipelineAction) =>
parseJsonCommandOutput(
await runner({
command: 'osascript',
args: [
'-l',
'JavaScript',
'-e',
ACTION_SCRIPT,
processName,
JSON.stringify(action),
],
}),
'macos-ax action',
),
screenshot: async (label: string) => {
const file = screenshotPath(context.runDir, context.sessionId, label);
try {
await runner({ command: 'screencapture', args: ['-x', file] });
} catch (error) {
if (bridge === undefined) {
throw error;
}
return parseJsonCommandOutput(
await runner({
command: bridge,
args: [
'screenshot',
bridgePayload(target, context, {
bundleId,
appPath,
label,
path: file,
}),
],
}),
'macos-xctest screenshot',
);
}
return { label, path: file };
},
logs: async () => [],
crash: async () => null,
close: async () => {
await runner({
command: 'osascript',
args: ['-l', 'JavaScript', '-e', QUIT_SCRIPT, processName],
}).catch(() => undefined);
},
};
},
};
}
Loading
Loading