From 466b1ec7443185e63ed1a9e2c1591f0891a70e3e Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 14:19:14 -0700 Subject: [PATCH] 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) --- ios-qa/executor/README.md | 23 ++++ ios-qa/executor/cli.ts | 24 ++++ ios-qa/executor/contract.ts | 77 ++++++++++++ .../examples/swiftui-gallery-flow.json | 49 ++++++++ ios-qa/executor/xcuitest-plan.test.ts | 83 +++++++++++++ ios-qa/executor/xcuitest-plan.ts | 113 ++++++++++++++++++ 6 files changed, 369 insertions(+) create mode 100644 ios-qa/executor/README.md create mode 100644 ios-qa/executor/cli.ts create mode 100644 ios-qa/executor/contract.ts create mode 100644 ios-qa/executor/examples/swiftui-gallery-flow.json create mode 100644 ios-qa/executor/xcuitest-plan.test.ts create mode 100644 ios-qa/executor/xcuitest-plan.ts diff --git a/ios-qa/executor/README.md b/ios-qa/executor/README.md new file mode 100644 index 000000000..cea09c549 --- /dev/null +++ b/ios-qa/executor/README.md @@ -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. diff --git a/ios-qa/executor/cli.ts b/ios-qa/executor/cli.ts new file mode 100644 index 000000000..d96fe365a --- /dev/null +++ b/ios-qa/executor/cli.ts @@ -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 '); + 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; +} diff --git a/ios-qa/executor/contract.ts b/ios-qa/executor/contract.ts new file mode 100644 index 000000000..4708d697d --- /dev/null +++ b/ios-qa/executor/contract.ts @@ -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; 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 }; + flow: IOSQAFlow; +} diff --git a/ios-qa/executor/examples/swiftui-gallery-flow.json b/ios-qa/executor/examples/swiftui-gallery-flow.json new file mode 100644 index 000000000..2e1d42d76 --- /dev/null +++ b/ios-qa/executor/examples/swiftui-gallery-flow.json @@ -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 + } + } + ] +} diff --git a/ios-qa/executor/xcuitest-plan.test.ts b/ios-qa/executor/xcuitest-plan.test.ts new file mode 100644 index 000000000..56c0dc96b --- /dev/null +++ b/ios-qa/executor/xcuitest-plan.test.ts @@ -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', + }); + }); +}); diff --git a/ios-qa/executor/xcuitest-plan.ts b/ios-qa/executor/xcuitest-plan.ts new file mode 100644 index 000000000..d9df62ed2 --- /dev/null +++ b/ios-qa/executor/xcuitest-plan.ts @@ -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, + }, + }; +}