mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 12:20:48 +02:00
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:
co-authored by
Claude Opus 4.8
parent
a84a6e233d
commit
466b1ec744
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user