-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ios-qa): adaptive XCUITest QA — reaches nested controls, verifies taps #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Clients following this output contract may assume fields that are absent for JSON/file errors and flow-version or target-kind failures. Describe Prompt for AI agents |
||
| `blocked` with remediation, or `unsupported` with the offending step id. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) })); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Malformed JSON or an unexpected input shape produces a Prompt for AI agents |
||
| process.exitCode = 1; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
|
|
||
| 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 } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Selector-based swipes cannot configure their resolution timeout even though the runner supports it. Adding Prompt for AI agents |
||
| | { id: string; action: 'wait'; verification: Verification }; | ||
|
|
||
| export interface IOSQAFlow { | ||
| version: 1; | ||
| name: string; | ||
| bundleIdentifier?: string; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: A flow without Prompt for AI agents |
||
| 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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', | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,113 @@ | ||||||
| import { resolve } from 'path'; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P0: Physical-device plans now bypass the repository's required DebugBridge/CoreDevice harness by routing Prompt for AI agentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Malformed JSON can escape the structured Prompt for AI agents |
||||||
| 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[] { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents |
||||||
| 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.' }; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Guard each Prompt for AI agents |
||||||
| 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) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: A whitespace-only project or workspace path is treated as configured, producing a Prompt for AI agents
Suggested change
|
||||||
| 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)) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Swipe step Add a check inside the step-validation loop for swipe steps: if Prompt for AI agents |
||||||
| 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}`, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Test credentials can be exposed in process arguments and plan logs because the complete flow, including Prompt for AI agents |
||||||
| `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, | ||||||
| }, | ||||||
| }; | ||||||
| } | ||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Runner consumers cannot receive the documented
blocked/unsupportedstep result; the checked-in Swift runner reports these as XCTest failures. Document the actual failure channel, or add a structured result artifact before promising this contract.Prompt for AI agents