feat(ios-qa): add XCUITest execution-plan module

Standalone planner that turns the IOSQAFlow JSON contract into an
xcodebuild XCUITest invocation for simulator or physical device, using
semantic selectors (identifier/role/label) and no coordinate taps.
Pure plan generation; covered by 7 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-21 14:19:14 -07:00
co-authored by Claude Opus 4.8
parent a84a6e233d
commit 466b1ec744
6 changed files with 369 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# XCUITest execution contract
This directory separates a logical iOS QA flow from where it runs. The same
semantic flow is routed to an iOS Simulator or a provisioned physical device.
It emits an `xcodebuild` argv specification; it does not invoke a shell, alter
signing, or provision a phone.
The checked-in XCUITest runner consumes inline base64 JSON from
`GSTACK_IOS_QA_FLOW_JSON_BASE64` on physical devices, because a device test
runner cannot open a path on the Mac. Simulator runs may also consume
`GSTACK_IOS_QA_FLOW_PATH`. It resolves each selector in
`selectorCandidates()` order, waits for hittability, performs the action, and
evaluates the optional post-action verification. A runner must return a
blocked/unsupported step result rather than falling back to screen coordinates.
Generate a plan:
```bash
bun ios-qa/executor/cli.ts flow.json target.json runner.json
```
The JSON result is either `ready` with an argument-safe `xcodebuild` command,
`blocked` with remediation, or `unsupported` with the offending step id.
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bun
import { readFileSync } from 'fs';
import { buildXCUITestPlan } from './xcuitest-plan';
import type { IOSQAFlow, IOSQATarget, XCUITestRunnerConfig } from './contract';
function usage(): never {
console.error('usage: bun ios-qa/executor/cli.ts <flow.json> <target.json> <runner.json>');
process.exit(2);
}
const [, , flowPath, targetPath, runnerPath] = process.argv;
if (!flowPath || !targetPath || !runnerPath) usage();
try {
const flow = JSON.parse(readFileSync(flowPath, 'utf8')) as IOSQAFlow;
const target = JSON.parse(readFileSync(targetPath, 'utf8')) as IOSQATarget;
const runner = JSON.parse(readFileSync(runnerPath, 'utf8')) as XCUITestRunnerConfig;
const result = buildXCUITestPlan(flow, target, runner, flowPath);
console.log(JSON.stringify(result, null, 2));
if (result.status !== 'ready') process.exitCode = 1;
} catch (error) {
console.error(JSON.stringify({ status: 'blocked', reason: error instanceof Error ? error.message : String(error) }));
process.exitCode = 1;
}
+77
View File
@@ -0,0 +1,77 @@
export type IOSQATarget =
| { kind: 'simulator'; udid: string }
| { kind: 'device'; udid: string };
export type ElementRole =
| 'button'
| 'cell'
| 'link'
| 'navigationBar'
| 'secureTextField'
| 'staticText'
| 'switch'
| 'textField';
export interface ElementSelector {
/** Stable accessibility identifiers are always attempted before fallback fields. */
identifier?: string;
label?: string;
role?: ElementRole;
}
export type Verification =
| { kind: 'exists'; selector: ElementSelector; timeoutMs?: number }
| { kind: 'notExists'; selector: ElementSelector; timeoutMs?: number }
| { kind: 'labelEquals'; selector: ElementSelector; value: string; timeoutMs?: number };
export type FlowStep =
| { id: string; action: 'launch'; arguments?: string[]; environment?: Record<string, string>; verify?: Verification }
| { id: string; action: 'tap'; selector: ElementSelector; timeoutMs?: number; verify?: Verification }
| { id: string; action: 'typeText'; selector: ElementSelector; text: string; clear?: boolean; timeoutMs?: number; verify?: Verification }
| { id: string; action: 'swipe'; direction: 'up' | 'down' | 'left' | 'right'; selector?: ElementSelector; verify?: Verification }
| { id: string; action: 'wait'; verification: Verification };
export interface IOSQAFlow {
version: 1;
name: string;
bundleIdentifier?: string;
steps: FlowStep[];
}
export interface XCUITestRunnerConfig {
projectPath?: string;
workspacePath?: string;
scheme: string;
testIdentifier: string;
derivedDataPath?: string;
resultBundlePath?: string;
}
export interface ExecutorCapabilities {
semanticSelectors: readonly ['identifier', 'role', 'label'];
actions: readonly ['launch', 'tap', 'typeText', 'swipe', 'wait'];
targets: readonly ['simulator', 'device'];
coordinateTaps: false;
postActionVerification: true;
}
export const XCUITEST_CAPABILITIES: ExecutorCapabilities = {
semanticSelectors: ['identifier', 'role', 'label'],
actions: ['launch', 'tap', 'typeText', 'swipe', 'wait'],
targets: ['simulator', 'device'],
coordinateTaps: false,
postActionVerification: true,
};
export type PlanResult =
| { status: 'ready'; plan: XCUITestExecutionPlan }
| { status: 'blocked'; reason: string; remediation: string }
| { status: 'unsupported'; reason: string; stepId?: string };
export interface XCUITestExecutionPlan {
backend: 'xcuitest';
target: IOSQATarget;
capabilities: ExecutorCapabilities;
command: { executable: 'xcodebuild'; args: string[]; env: Record<string, string> };
flow: IOSQAFlow;
}
@@ -0,0 +1,49 @@
{
"version": 1,
"name": "swiftui-gallery-smoke",
"bundleIdentifier": "com.gstack.iosqa.adaptive.swiftui",
"steps": [
{ "id": "launch", "action": "launch" },
{
"id": "increment",
"action": "tap",
"selector": { "identifier": "swiftui.increment", "label": "Increment", "role": "button" },
"verify": {
"kind": "labelEquals",
"selector": { "identifier": "swiftui.count", "role": "staticText" },
"value": "Count: 1"
}
},
{
"id": "search",
"action": "typeText",
"selector": { "identifier": "swiftui.search", "label": "Search terms", "role": "textField" },
"text": "adaptive",
"verify": {
"kind": "labelEquals",
"selector": { "identifier": "swiftui.echo", "role": "staticText" },
"value": "Echo: adaptive"
}
},
{
"id": "open-sheet",
"action": "tap",
"selector": { "identifier": "swiftui.sheet.open", "role": "button" },
"verify": {
"kind": "exists",
"selector": { "identifier": "swiftui.sheet.content" },
"timeoutMs": 2000
}
},
{
"id": "close-sheet",
"action": "tap",
"selector": { "identifier": "swiftui.sheet.done", "role": "button" },
"verify": {
"kind": "notExists",
"selector": { "identifier": "swiftui.sheet.content" },
"timeoutMs": 2000
}
}
]
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test';
import type { IOSQAFlow, XCUITestRunnerConfig } from './contract';
import { buildXCUITestPlan, selectorCandidates } from './xcuitest-plan';
const runner: XCUITestRunnerConfig = {
projectPath: '/tmp/GStackIOSQARunner.xcodeproj',
scheme: 'GStackIOSQARunner',
testIdentifier: 'GStackIOSQARunnerUITests/FlowTests/testFlow',
};
const flow: IOSQAFlow = {
version: 1,
name: 'login',
steps: [
{ id: 'launch', action: 'launch' },
{ id: 'email', action: 'typeText', selector: { identifier: 'login.email', label: 'Email', role: 'textField' }, text: 'qa@example.com' },
{ id: 'submit', action: 'tap', selector: { identifier: 'login.submit', label: 'Sign in', role: 'button' }, verify: { kind: 'exists', selector: { identifier: 'home.title' } } },
],
};
describe('buildXCUITestPlan', () => {
test('orders stable identifiers before labels and preserves role narrowing', () => {
expect(selectorCandidates({ identifier: 'cart.checkout', label: 'Checkout', role: 'button' })).toEqual([
{ strategy: 'identifier', value: 'cart.checkout', role: 'button' },
{ strategy: 'label', value: 'Checkout', role: 'button' },
]);
});
test.each([
[{ kind: 'simulator', udid: 'SIM-1' } as const, 'simulator', 'iOS Simulator'],
[{ kind: 'device', udid: 'PHONE-1' } as const, 'device', 'iOS'],
])('routes %s through the same semantic flow contract', (target, kind, platform) => {
const result = buildXCUITestPlan(flow, target, runner, './flow.json');
expect(result.status).toBe('ready');
if (result.status !== 'ready') return;
expect(result.plan.target.kind).toBe(kind);
expect(result.plan.command.args).toContain(`platform=${platform},id=${target.udid}`);
expect(result.plan.command.args).toContain(`-only-testing:${runner.testIdentifier}`);
expect(result.plan.command.args).toContain(`GSTACK_IOS_QA_TARGET_KIND_VALUE=${kind}`);
expect(result.plan.command.env.GSTACK_IOS_QA_TARGET_KIND).toBe(kind);
expect(JSON.parse(Buffer.from(result.plan.command.env.GSTACK_IOS_QA_FLOW_JSON_BASE64, 'base64').toString('utf8'))).toMatchObject({
version: 1,
name: 'login',
});
expect(result.plan.command.args.some((arg) => arg.startsWith('GSTACK_IOS_QA_FLOW_JSON_BASE64_VALUE='))).toBe(true);
expect(result.plan.flow.steps[1]).toMatchObject({ timeoutMs: 10_000 });
expect(result.plan.capabilities.coordinateTaps).toBe(false);
expect(result.plan.capabilities.semanticSelectors).toEqual(['identifier', 'role', 'label']);
});
test('returns unsupported instead of guessing a coordinate for an unresolvable selector', () => {
const bad: IOSQAFlow = { ...flow, steps: [{ id: 'mystery', action: 'tap', selector: { role: 'button' } }] };
expect(buildXCUITestPlan(bad, { kind: 'simulator', udid: 'SIM-1' }, runner, './flow.json')).toEqual({
status: 'unsupported',
reason: 'selector needs an accessibility identifier or label',
stepId: 'mystery',
});
});
test('blocks before execution when runner configuration is ambiguous', () => {
const result = buildXCUITestPlan(flow, { kind: 'device', udid: 'PHONE-1' }, { ...runner, workspacePath: '/tmp/a.xcworkspace' }, './flow.json');
expect(result).toMatchObject({ status: 'blocked', reason: 'runner needs exactly one projectPath or workspacePath' });
});
test('builds argv without shell interpolation for physical devices', () => {
const result = buildXCUITestPlan(flow, { kind: 'device', udid: 'PHONE 1; touch /tmp/pwned' }, runner, './flow.json');
expect(result.status).toBe('ready');
if (result.status !== 'ready') return;
expect(result.plan.command.executable).toBe('xcodebuild');
expect(result.plan.command.args).toContain('platform=iOS,id=PHONE 1; touch /tmp/pwned');
expect(result.plan.command.args).not.toContain('touch');
});
test('reports malformed JSON actions and target kinds as unsupported', () => {
const badAction = { ...flow, steps: [{ id: 'bad', action: 'coordinateTap', x: 10, y: 20 }] } as unknown as IOSQAFlow;
expect(buildXCUITestPlan(badAction, { kind: 'simulator', udid: 'SIM-1' }, runner, './flow.json')).toMatchObject({
status: 'unsupported', reason: 'action coordinateTap is unsupported', stepId: 'bad',
});
expect(buildXCUITestPlan(flow, { kind: 'watch' as 'device', udid: 'WATCH-1' }, runner, './flow.json')).toMatchObject({
status: 'unsupported', reason: 'target kind watch is unsupported',
});
});
});
+113
View File
@@ -0,0 +1,113 @@
import { resolve } from 'path';
import type {
ElementSelector,
IOSQAFlow,
IOSQATarget,
PlanResult,
XCUITestRunnerConfig,
} from './contract';
import { XCUITEST_CAPABILITIES } from './contract';
const DEFAULT_TIMEOUT_MS = 10_000;
export interface SelectorCandidate {
strategy: 'identifier' | 'label';
value: string;
role?: ElementSelector['role'];
}
/** Ordered queries for the XCUITest runner. Never lets a human label outrank a stable id. */
export function selectorCandidates(selector: ElementSelector): SelectorCandidate[] {
const candidates: SelectorCandidate[] = [];
if (selector.identifier) candidates.push({ strategy: 'identifier', value: selector.identifier, role: selector.role });
if (selector.label) candidates.push({ strategy: 'label', value: selector.label, role: selector.role });
return candidates;
}
function selectorError(selector: ElementSelector): string | null {
if (!selector.identifier && !selector.label) return 'selector needs an accessibility identifier or label';
if (selector.identifier !== undefined && selector.identifier.trim() === '') return 'selector identifier cannot be empty';
if (selector.label !== undefined && selector.label.trim() === '') return 'selector label cannot be empty';
return null;
}
export function normalizeFlow(flow: IOSQAFlow): IOSQAFlow {
return {
...flow,
steps: flow.steps.map((step) => {
if (step.action === 'tap' || step.action === 'typeText') {
return { ...step, timeoutMs: step.timeoutMs ?? DEFAULT_TIMEOUT_MS };
}
return step;
}),
};
}
export function buildXCUITestPlan(
flow: IOSQAFlow,
target: IOSQATarget,
config: XCUITestRunnerConfig,
planPath: string,
): PlanResult {
if (flow.version !== 1) return { status: 'unsupported', reason: `flow version ${String(flow.version)} is unsupported` };
if (target.kind !== 'simulator' && target.kind !== 'device') {
return { status: 'unsupported', reason: `target kind ${String(target.kind)} is unsupported` };
}
if (!flow.name.trim()) return { status: 'blocked', reason: 'flow name is empty', remediation: 'Give the flow a stable name.' };
if (!flow.steps.length) return { status: 'blocked', reason: 'flow has no steps', remediation: 'Add at least one semantic action or verification.' };
if (!target.udid.trim()) return { status: 'blocked', reason: `${target.kind} UDID is missing`, remediation: `Select a booted ${target.kind} and pass its UDID.` };
if (!!config.projectPath === !!config.workspacePath) {
return { status: 'blocked', reason: 'runner needs exactly one projectPath or workspacePath', remediation: 'Point to the existing XCUITest runner project or workspace.' };
}
if (!config.scheme.trim() || !config.testIdentifier.trim()) {
return { status: 'blocked', reason: 'runner scheme or test identifier is missing', remediation: 'Configure the checked-in XCUITest runner target.' };
}
for (const step of flow.steps) {
if (!['launch', 'tap', 'typeText', 'swipe', 'wait'].includes(step.action)) {
return { status: 'unsupported', reason: `action ${String(step.action)} is unsupported`, stepId: step.id };
}
const selectors: ElementSelector[] = [];
if ('selector' in step && step.selector) selectors.push(step.selector);
const verification = 'verify' in step ? step.verify : step.action === 'wait' ? step.verification : undefined;
if (verification) selectors.push(verification.selector);
for (const selector of selectors) {
const error = selectorError(selector);
if (error) return { status: 'unsupported', reason: error, stepId: step.id };
}
}
const normalized = normalizeFlow(flow);
const encodedFlow = Buffer.from(JSON.stringify(normalized), 'utf8').toString('base64');
const args = ['test'];
if (config.workspacePath) args.push('-workspace', config.workspacePath);
else args.push('-project', config.projectPath!);
const destinationPlatform = target.kind === 'simulator' ? 'iOS Simulator' : 'iOS';
args.push('-scheme', config.scheme, '-destination', `platform=${destinationPlatform},id=${target.udid}`, `-only-testing:${config.testIdentifier}`);
args.push(
`GSTACK_IOS_QA_FLOW_PATH_VALUE=${resolve(planPath)}`,
`GSTACK_IOS_QA_FLOW_JSON_BASE64_VALUE=${encodedFlow}`,
`GSTACK_IOS_QA_TARGET_KIND_VALUE=${target.kind}`,
);
if (config.derivedDataPath) args.push('-derivedDataPath', config.derivedDataPath);
if (config.resultBundlePath) args.push('-resultBundlePath', config.resultBundlePath);
return {
status: 'ready',
plan: {
backend: 'xcuitest',
target,
capabilities: XCUITEST_CAPABILITIES,
command: {
executable: 'xcodebuild',
args,
env: {
GSTACK_IOS_QA_FLOW_PATH: resolve(planPath),
GSTACK_IOS_QA_FLOW_JSON_BASE64: encodedFlow,
GSTACK_IOS_QA_TARGET_KIND: target.kind,
},
},
flow: normalized,
},
};
}