mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 20:30:47 +02:00
feat(ios-qa): adaptive XCUITest QA — semantic tap, nested content (#14)
feat(ios-qa): adaptive XCUITest QA — reaches nested controls, verifies taps
This commit is contained in:
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+79
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct AdaptiveSwiftUIFixtureApp: App {
|
||||||
|
var body: some Scene { WindowGroup { GalleryView() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GalleryView: View {
|
||||||
|
@State private var count = 0
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var showingSheet = false
|
||||||
|
@State private var showingAlert = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section("Actions") {
|
||||||
|
Button("Increment") { count += 1 }
|
||||||
|
.accessibilityIdentifier("swiftui.increment")
|
||||||
|
Text("Count: \(count)")
|
||||||
|
.accessibilityIdentifier("swiftui.count")
|
||||||
|
Button("Unavailable") {}
|
||||||
|
.disabled(true)
|
||||||
|
.accessibilityIdentifier("swiftui.disabled")
|
||||||
|
Button("Show sheet") { showingSheet = true }
|
||||||
|
.accessibilityIdentifier("swiftui.sheet.open")
|
||||||
|
Button("Show alert") { showingAlert = true }
|
||||||
|
.accessibilityIdentifier("swiftui.alert.open")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Input") {
|
||||||
|
TextField("Search terms", text: $query)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityIdentifier("swiftui.search")
|
||||||
|
Text("Echo: \(query)")
|
||||||
|
.accessibilityIdentifier("swiftui.echo")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Nested horizontal scroll") {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack {
|
||||||
|
ForEach(0..<12) { index in
|
||||||
|
Button("Card \(index)") {}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.accessibilityIdentifier("swiftui.card.\(index)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("swiftui.horizontal-scroll")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Long vertical list") {
|
||||||
|
ForEach(0..<35) { index in
|
||||||
|
NavigationLink("Row \(index)") {
|
||||||
|
Text("Detail \(index)")
|
||||||
|
.accessibilityIdentifier("swiftui.detail.\(index)")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("swiftui.row.\(index)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("swiftui.list")
|
||||||
|
.navigationTitle("SwiftUI Gallery")
|
||||||
|
.sheet(isPresented: $showingSheet) {
|
||||||
|
NavigationStack {
|
||||||
|
Text("Sheet content").accessibilityIdentifier("swiftui.sheet.content")
|
||||||
|
.toolbar {
|
||||||
|
Button("Done") { showingSheet = false }
|
||||||
|
.accessibilityIdentifier("swiftui.sheet.done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Confirm action", isPresented: $showingAlert) {
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Confirm") { count += 10 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>SwiftUI QA Gallery</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
window.rootViewController = UINavigationController(rootViewController: UIKitGalleryController())
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
self.window = window
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UIKitGalleryController: UIViewController, UITextFieldDelegate {
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let countLabel = UILabel()
|
||||||
|
private let echoLabel = UILabel()
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = "UIKit Gallery"
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
|
let scroll = UIScrollView()
|
||||||
|
scroll.accessibilityIdentifier = "uikit.vertical-scroll"
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 16
|
||||||
|
stack.layoutMargins = UIEdgeInsets(top: 24, left: 20, bottom: 24, right: 20)
|
||||||
|
stack.isLayoutMarginsRelativeArrangement = true
|
||||||
|
view.addSubview(scroll); scroll.addSubview(stack)
|
||||||
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scroll.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
addButton("Increment", id: "uikit.increment", action: #selector(increment))
|
||||||
|
countLabel.text = "Count: 0"; countLabel.accessibilityIdentifier = "uikit.count"; stack.addArrangedSubview(countLabel)
|
||||||
|
let disabled = addButton("Unavailable", id: "uikit.disabled", action: #selector(noop)); disabled.isEnabled = false
|
||||||
|
addButton("Show sheet", id: "uikit.sheet.open", action: #selector(showSheet))
|
||||||
|
addButton("Show alert", id: "uikit.alert.open", action: #selector(showAlert))
|
||||||
|
|
||||||
|
let field = UITextField(); field.placeholder = "Search terms"; field.borderStyle = .roundedRect
|
||||||
|
field.accessibilityIdentifier = "uikit.search"; field.delegate = self; field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
|
||||||
|
stack.addArrangedSubview(field)
|
||||||
|
echoLabel.text = "Echo: "; echoLabel.accessibilityIdentifier = "uikit.echo"; stack.addArrangedSubview(echoLabel)
|
||||||
|
|
||||||
|
let horizontal = UIScrollView(); horizontal.accessibilityIdentifier = "uikit.horizontal-scroll"
|
||||||
|
let cards = UIStackView(); cards.axis = .horizontal; cards.spacing = 12
|
||||||
|
horizontal.addSubview(cards); cards.translatesAutoresizingMaskIntoConstraints = false; horizontal.heightAnchor.constraint(equalToConstant: 52).isActive = true
|
||||||
|
NSLayoutConstraint.activate([cards.topAnchor.constraint(equalTo: horizontal.contentLayoutGuide.topAnchor), cards.bottomAnchor.constraint(equalTo: horizontal.contentLayoutGuide.bottomAnchor), cards.leadingAnchor.constraint(equalTo: horizontal.contentLayoutGuide.leadingAnchor), cards.trailingAnchor.constraint(equalTo: horizontal.contentLayoutGuide.trailingAnchor), cards.heightAnchor.constraint(equalTo: horizontal.frameLayoutGuide.heightAnchor)])
|
||||||
|
for index in 0..<12 { let button = UIButton(type: .system); button.setTitle("Card \(index)", for: .normal); button.accessibilityIdentifier = "uikit.card.\(index)"; cards.addArrangedSubview(button) }
|
||||||
|
stack.addArrangedSubview(horizontal)
|
||||||
|
|
||||||
|
for index in 0..<35 { addButton("Row \(index)", id: "uikit.row.\(index)", action: #selector(openRow(_:)), tag: index) }
|
||||||
|
|
||||||
|
let occluded = addButton("Occluded target", id: "uikit.occluded", action: #selector(noop))
|
||||||
|
let blocker = UIView(); blocker.backgroundColor = .systemBackground; blocker.accessibilityIdentifier = "uikit.occluder"; blocker.isAccessibilityElement = true
|
||||||
|
occluded.addSubview(blocker); blocker.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([blocker.topAnchor.constraint(equalTo: occluded.topAnchor), blocker.bottomAnchor.constraint(equalTo: occluded.bottomAnchor), blocker.leadingAnchor.constraint(equalTo: occluded.leadingAnchor), blocker.trailingAnchor.constraint(equalTo: occluded.trailingAnchor)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult private func addButton(_ title: String, id: String, action: Selector, tag: Int = 0) -> UIButton {
|
||||||
|
let button = UIButton(type: .system); button.configuration = .bordered(); button.setTitle(title, for: .normal); button.accessibilityIdentifier = id; button.tag = tag
|
||||||
|
button.addTarget(self, action: action, for: .touchUpInside); stack.addArrangedSubview(button); return button
|
||||||
|
}
|
||||||
|
@objc private func increment() { count += 1; countLabel.text = "Count: \(count)" }
|
||||||
|
@objc private func noop() {}
|
||||||
|
@objc private func textChanged(_ sender: UITextField) { echoLabel.text = "Echo: \(sender.text ?? "")" }
|
||||||
|
@objc private func openRow(_ sender: UIButton) { navigationController?.pushViewController(DetailController(index: sender.tag), animated: true) }
|
||||||
|
@objc private func showSheet() { let vc = UIViewController(); vc.view.backgroundColor = .systemBackground; vc.view.accessibilityIdentifier = "uikit.sheet.content"; vc.title = "Sheet content"; let nav = UINavigationController(rootViewController: vc); vc.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(closeSheet)); vc.navigationItem.rightBarButtonItem?.accessibilityIdentifier = "uikit.sheet.done"; present(nav, animated: true) }
|
||||||
|
@objc private func closeSheet() { dismiss(animated: true) }
|
||||||
|
@objc private func showAlert() { let alert = UIAlertController(title: "Confirm action", message: nil, preferredStyle: .alert); alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)); alert.addAction(UIAlertAction(title: "Confirm", style: .default) { _ in self.count += 10; self.countLabel.text = "Count: \(self.count)" }); present(alert, animated: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DetailController: UIViewController {
|
||||||
|
init(index: Int) { super.init(nibName: nil, bundle: nil); title = "Detail \(index)"; view.accessibilityIdentifier = "uikit.detail.\(index)" }
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
override func viewDidLoad() { super.viewDidLoad(); view.backgroundColor = .systemBackground }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>UIKit QA Gallery</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class AdaptiveFixtureUITests: XCTestCase {
|
||||||
|
override func setUpWithError() throws { continueAfterFailure = false }
|
||||||
|
|
||||||
|
func testSwiftUIActionsInputModalAndVerticalNavigation() {
|
||||||
|
let app = XCUIApplication(bundleIdentifier: "com.gstack.iosqa.adaptive.swiftui")
|
||||||
|
app.launch()
|
||||||
|
app.buttons["swiftui.increment"].tap()
|
||||||
|
XCTAssertEqual(app.staticTexts["swiftui.count"].label, "Count: 1")
|
||||||
|
XCTAssertFalse(app.buttons["swiftui.disabled"].isEnabled)
|
||||||
|
app.textFields["swiftui.search"].tap(); app.textFields["swiftui.search"].typeText("adapt")
|
||||||
|
XCTAssertEqual(app.staticTexts["swiftui.echo"].label, "Echo: adapt")
|
||||||
|
app.buttons["swiftui.sheet.open"].tap(); XCTAssertTrue(app.staticTexts["swiftui.sheet.content"].waitForExistence(timeout: 2)); app.buttons["swiftui.sheet.done"].tap()
|
||||||
|
app.buttons["swiftui.alert.open"].tap(); app.alerts.buttons["Confirm"].tap()
|
||||||
|
XCTAssertEqual(app.staticTexts["swiftui.count"].label, "Count: 11")
|
||||||
|
let row = app.buttons["swiftui.row.30"]; for _ in 0..<12 where !row.isHittable { app.swipeUp() }; XCTAssertTrue(row.isHittable); row.tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["swiftui.detail.30"].waitForExistence(timeout: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUIKitActionsInputModalAndVerticalNavigation() {
|
||||||
|
let app = XCUIApplication(bundleIdentifier: "com.gstack.iosqa.adaptive.uikit")
|
||||||
|
app.launch()
|
||||||
|
app.buttons["uikit.increment"].tap()
|
||||||
|
XCTAssertEqual(app.staticTexts["uikit.count"].label, "Count: 1")
|
||||||
|
XCTAssertFalse(app.buttons["uikit.disabled"].isEnabled)
|
||||||
|
app.textFields["uikit.search"].tap(); app.textFields["uikit.search"].typeText("adapt")
|
||||||
|
XCTAssertEqual(app.staticTexts["uikit.echo"].label, "Echo: adapt")
|
||||||
|
app.buttons["uikit.sheet.open"].tap(); XCTAssertTrue(app.otherElements["uikit.sheet.content"].waitForExistence(timeout: 2)); app.buttons["uikit.sheet.done"].tap()
|
||||||
|
app.buttons["uikit.alert.open"].tap(); app.alerts.buttons["Confirm"].tap()
|
||||||
|
XCTAssertEqual(app.staticTexts["uikit.count"].label, "Count: 11")
|
||||||
|
let row = app.buttons["uikit.row.30"]; for _ in 0..<12 where !row.isHittable { app.swipeUp() }; XCTAssertTrue(row.isHittable); row.tap()
|
||||||
|
XCTAssertTrue(app.otherElements["uikit.detail.30"].waitForExistence(timeout: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNestedHorizontalScrollingUsesContainerNotScreenCoordinates() {
|
||||||
|
for bundle in ["com.gstack.iosqa.adaptive.swiftui", "com.gstack.iosqa.adaptive.uikit"] {
|
||||||
|
let app = XCUIApplication(bundleIdentifier: bundle); app.launch()
|
||||||
|
let prefix = bundle.hasSuffix("swiftui") ? "swiftui" : "uikit"
|
||||||
|
let container = app.scrollViews["\(prefix).horizontal-scroll"]
|
||||||
|
for _ in 0..<4 where !container.exists { app.swipeUp() }
|
||||||
|
XCTAssertTrue(container.waitForExistence(timeout: 2))
|
||||||
|
let card = app.buttons["\(prefix).card.11"]
|
||||||
|
for _ in 0..<8 where !card.isHittable { container.swipeLeft() }
|
||||||
|
XCTAssertTrue(card.isHittable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUIKitOccludedControlExistsButIsNotHittable() {
|
||||||
|
let app = XCUIApplication(bundleIdentifier: "com.gstack.iosqa.adaptive.uikit"); app.launch()
|
||||||
|
let target = app.buttons["uikit.occluded"]
|
||||||
|
for _ in 0..<16 where !target.exists { app.swipeUp() }
|
||||||
|
XCTAssertTrue(target.exists)
|
||||||
|
XCTAssertFalse(target.isHittable)
|
||||||
|
}
|
||||||
|
}
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Concrete XCUITest adapter for the JSON contract in ios-qa/executor/contract.ts.
|
||||||
|
/// The environment supplies the flow path; xcodebuild remains responsible for routing
|
||||||
|
/// this exact runner to either a simulator or a provisioned physical device.
|
||||||
|
final class GStackFlowRunnerUITests: XCTestCase {
|
||||||
|
private struct Flow: Decodable {
|
||||||
|
let version: Int
|
||||||
|
let name: String
|
||||||
|
let bundleIdentifier: String?
|
||||||
|
let steps: [Step]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Selector: Decodable {
|
||||||
|
let identifier: String?
|
||||||
|
let label: String?
|
||||||
|
let role: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Verification: Decodable {
|
||||||
|
let kind: String
|
||||||
|
let selector: Selector
|
||||||
|
let value: String?
|
||||||
|
let timeoutMs: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Step: Decodable {
|
||||||
|
let id: String
|
||||||
|
let action: String
|
||||||
|
let arguments: [String]?
|
||||||
|
let environment: [String: String]?
|
||||||
|
let selector: Selector?
|
||||||
|
let text: String?
|
||||||
|
let clear: Bool?
|
||||||
|
let timeoutMs: Int?
|
||||||
|
let direction: String?
|
||||||
|
let verify: Verification?
|
||||||
|
let verification: Verification?
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum RunnerError: Error, CustomStringConvertible {
|
||||||
|
case blocked(String)
|
||||||
|
case unsupported(String)
|
||||||
|
case step(String, String)
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .blocked(let message): return "BLOCKED: \(message)"
|
||||||
|
case .unsupported(let message): return "UNSUPPORTED: \(message)"
|
||||||
|
case .step(let id, let message): return "STEP \(id) FAILED: \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFlow() throws {
|
||||||
|
do {
|
||||||
|
let flow = try loadFlow()
|
||||||
|
guard flow.version == 1 else { throw RunnerError.unsupported("flow version \(flow.version)") }
|
||||||
|
guard let bundleIdentifier = flow.bundleIdentifier, !bundleIdentifier.isEmpty else {
|
||||||
|
throw RunnerError.blocked("bundleIdentifier is required by the XCUITest runner")
|
||||||
|
}
|
||||||
|
let app = XCUIApplication(bundleIdentifier: bundleIdentifier)
|
||||||
|
for step in flow.steps {
|
||||||
|
try execute(step, in: app)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
XCTFail(String(describing: error))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFlow() throws -> Flow {
|
||||||
|
if let encoded = ProcessInfo.processInfo.environment["GSTACK_IOS_QA_FLOW_JSON_BASE64"],
|
||||||
|
!encoded.isEmpty {
|
||||||
|
guard let data = Data(base64Encoded: encoded) else {
|
||||||
|
throw RunnerError.blocked("GSTACK_IOS_QA_FLOW_JSON_BASE64 is invalid")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(Flow.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw RunnerError.blocked("cannot decode inline flow JSON: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let path = ProcessInfo.processInfo.environment["GSTACK_IOS_QA_FLOW_PATH"], !path.isEmpty else {
|
||||||
|
throw RunnerError.blocked("neither inline flow JSON nor GSTACK_IOS_QA_FLOW_PATH is set")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(Flow.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
|
||||||
|
} catch {
|
||||||
|
throw RunnerError.blocked("cannot decode flow at \(path): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func execute(_ step: Step, in app: XCUIApplication) throws {
|
||||||
|
do {
|
||||||
|
switch step.action {
|
||||||
|
case "launch":
|
||||||
|
app.launchArguments = step.arguments ?? []
|
||||||
|
app.launchEnvironment = step.environment ?? [:]
|
||||||
|
app.launch()
|
||||||
|
case "tap":
|
||||||
|
let resolved = try resolve(requiredSelector(step), in: app, timeoutMs: step.timeoutMs)
|
||||||
|
// If the identifier lands on a wrapper, target the actual switch inside it.
|
||||||
|
let element = resolved.elementType == .switch
|
||||||
|
? resolved
|
||||||
|
: (resolved.switches.firstMatch.exists ? resolved.switches.firstMatch : resolved)
|
||||||
|
guard waitUntilHittable(element, timeoutMs: step.timeoutMs) else {
|
||||||
|
throw RunnerError.step(step.id, "element exists but is not hittable")
|
||||||
|
}
|
||||||
|
if element.elementType == .switch {
|
||||||
|
try tapSwitch(element, stepID: step.id)
|
||||||
|
} else {
|
||||||
|
element.tap()
|
||||||
|
}
|
||||||
|
case "typeText":
|
||||||
|
let element = try resolve(requiredSelector(step), in: app, timeoutMs: step.timeoutMs)
|
||||||
|
guard waitUntilHittable(element, timeoutMs: step.timeoutMs) else {
|
||||||
|
throw RunnerError.step(step.id, "text element exists but is not hittable")
|
||||||
|
}
|
||||||
|
guard let text = step.text else { throw RunnerError.step(step.id, "typeText is missing text") }
|
||||||
|
element.tap()
|
||||||
|
if step.clear == true, let current = element.value as? String, !current.isEmpty {
|
||||||
|
element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: current.count))
|
||||||
|
}
|
||||||
|
element.typeText(text)
|
||||||
|
case "swipe":
|
||||||
|
let element = try step.selector.map { try resolve($0, in: app, timeoutMs: step.timeoutMs) } ?? app
|
||||||
|
switch step.direction {
|
||||||
|
case "up": element.swipeUp()
|
||||||
|
case "down": element.swipeDown()
|
||||||
|
case "left": element.swipeLeft()
|
||||||
|
case "right": element.swipeRight()
|
||||||
|
default: throw RunnerError.step(step.id, "unsupported swipe direction \(step.direction ?? "<missing>")")
|
||||||
|
}
|
||||||
|
case "wait":
|
||||||
|
guard let verification = step.verification else { throw RunnerError.step(step.id, "wait is missing verification") }
|
||||||
|
try verify(verification, in: app, stepID: step.id)
|
||||||
|
default:
|
||||||
|
throw RunnerError.unsupported("action \(step.action) at step \(step.id)")
|
||||||
|
}
|
||||||
|
if let verification = step.verify {
|
||||||
|
try verify(verification, in: app, stepID: step.id)
|
||||||
|
}
|
||||||
|
} catch let error as RunnerError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw RunnerError.step(step.id, String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requiredSelector(_ step: Step) throws -> Selector {
|
||||||
|
guard let selector = step.selector else { throw RunnerError.step(step.id, "action is missing selector") }
|
||||||
|
return selector
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifier is resolved first. A label is only a fallback after the identifier
|
||||||
|
/// query has had its bounded existence wait; no screen-coordinate fallback exists.
|
||||||
|
private func resolve(_ selector: Selector, in app: XCUIApplication, timeoutMs: Int?) throws -> XCUIElement {
|
||||||
|
let query = query(for: selector.role, in: app)
|
||||||
|
let timeout = seconds(timeoutMs)
|
||||||
|
if let identifier = selector.identifier, !identifier.isEmpty {
|
||||||
|
let candidate = query.matching(identifier: identifier).firstMatch
|
||||||
|
if candidate.waitForExistence(timeout: timeout) { return candidate }
|
||||||
|
}
|
||||||
|
if let label = selector.label, !label.isEmpty {
|
||||||
|
let candidate = query.matching(NSPredicate(format: "label == %@", label)).firstMatch
|
||||||
|
if candidate.waitForExistence(timeout: timeout) { return candidate }
|
||||||
|
}
|
||||||
|
throw RunnerError.blocked("no element resolved by identifier/label (role: \(selector.role ?? "any"))")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func query(for role: String?, in app: XCUIApplication) -> XCUIElementQuery {
|
||||||
|
let type: XCUIElement.ElementType
|
||||||
|
switch role {
|
||||||
|
case nil: type = .any
|
||||||
|
case "button": type = .button
|
||||||
|
case "cell": type = .cell
|
||||||
|
case "link": type = .link
|
||||||
|
case "navigationBar": type = .navigationBar
|
||||||
|
case "secureTextField": type = .secureTextField
|
||||||
|
case "staticText": type = .staticText
|
||||||
|
case "switch": type = .switch
|
||||||
|
case "textField": type = .textField
|
||||||
|
default: type = .any
|
||||||
|
}
|
||||||
|
return app.descendants(matching: type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle a switch and confirm its value actually changed. A SwiftUI Toggle
|
||||||
|
/// spans its whole row, so XCUIElement.tap() (geometric center) lands on the
|
||||||
|
/// label and misses the control. Center-tap first (correct for a narrow native
|
||||||
|
/// switch); if the value doesn't move, retry on the trailing edge via an
|
||||||
|
/// element-anchored normalized offset — still adaptive, resolved from the
|
||||||
|
/// element's live frame, not a raw screen coordinate. If it still doesn't
|
||||||
|
/// change, that's an interaction failure, reported as such rather than as a
|
||||||
|
/// confirmed product defect.
|
||||||
|
private func tapSwitch(_ element: XCUIElement, stepID: String) throws {
|
||||||
|
let before = element.value as? String
|
||||||
|
element.tap()
|
||||||
|
if let before, settledValue(of: element) == before {
|
||||||
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
if let before, let after = settledValue(of: element), before == after {
|
||||||
|
throw RunnerError.step(stepID, "switch tap did not change its value (stayed \(before.debugDescription)); the control was not activated, so this is an interaction failure, not a confirmed product defect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-read a control's value after a short settle so a post-tap comparison
|
||||||
|
/// reflects the committed state, not an in-flight animation frame.
|
||||||
|
private func settledValue(of element: XCUIElement) -> String? {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||||
|
return element.value as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitUntilHittable(_ element: XCUIElement, timeoutMs: Int?) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(seconds(timeoutMs))
|
||||||
|
repeat {
|
||||||
|
if element.exists && element.isHittable { return true }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
} while Date() < deadline
|
||||||
|
return element.exists && element.isHittable
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verify(_ verification: Verification, in app: XCUIApplication, stepID: String) throws {
|
||||||
|
let timeout = verification.timeoutMs
|
||||||
|
switch verification.kind {
|
||||||
|
case "exists":
|
||||||
|
_ = try resolve(verification.selector, in: app, timeoutMs: timeout)
|
||||||
|
case "notExists":
|
||||||
|
let candidates = unresolvedCandidates(verification.selector, in: app)
|
||||||
|
let deadline = Date().addingTimeInterval(seconds(timeout))
|
||||||
|
while candidates.contains(where: \.exists), Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
}
|
||||||
|
guard !candidates.contains(where: \.exists) else { throw RunnerError.step(stepID, "expected element not to exist") }
|
||||||
|
case "labelEquals":
|
||||||
|
guard let expected = verification.value else { throw RunnerError.step(stepID, "labelEquals is missing value") }
|
||||||
|
let element = try resolve(verification.selector, in: app, timeoutMs: timeout)
|
||||||
|
guard element.label == expected else {
|
||||||
|
throw RunnerError.step(stepID, "expected label \(expected.debugDescription), got \(element.label.debugDescription)")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw RunnerError.unsupported("verification \(verification.kind) at step \(stepID)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unresolvedCandidates(_ selector: Selector, in app: XCUIApplication) -> [XCUIElement] {
|
||||||
|
let query = query(for: selector.role, in: app)
|
||||||
|
var elements: [XCUIElement] = []
|
||||||
|
if let identifier = selector.identifier, !identifier.isEmpty { elements.append(query.matching(identifier: identifier).firstMatch) }
|
||||||
|
if let label = selector.label, !label.isEmpty { elements.append(query.matching(NSPredicate(format: "label == %@", label)).firstMatch) }
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seconds(_ timeoutMs: Int?) -> TimeInterval {
|
||||||
|
TimeInterval(timeoutMs ?? 10_000) / 1_000
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -47,3 +47,73 @@ targets:
|
|||||||
SWIFT_VERSION: "5.9"
|
SWIFT_VERSION: "5.9"
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
|
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
|
||||||
ENABLE_PREVIEWS: YES
|
ENABLE_PREVIEWS: YES
|
||||||
|
|
||||||
|
AdaptiveSwiftUIFixture:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "16.0"
|
||||||
|
sources:
|
||||||
|
- path: Sources/AdaptiveSwiftUIFixture
|
||||||
|
info:
|
||||||
|
path: Sources/AdaptiveSwiftUIFixture/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: SwiftUI QA Gallery
|
||||||
|
UILaunchScreen: {}
|
||||||
|
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.gstack.iosqa.adaptive.swiftui
|
||||||
|
CODE_SIGNING_ALLOWED: NO
|
||||||
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
|
||||||
|
AdaptiveUIKitFixture:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "16.0"
|
||||||
|
sources:
|
||||||
|
- path: Sources/AdaptiveUIKitFixture
|
||||||
|
info:
|
||||||
|
path: Sources/AdaptiveUIKitFixture/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: UIKit QA Gallery
|
||||||
|
UILaunchScreen: {}
|
||||||
|
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.gstack.iosqa.adaptive.uikit
|
||||||
|
CODE_SIGNING_ALLOWED: NO
|
||||||
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
|
||||||
|
AdaptiveFixtureUITests:
|
||||||
|
type: bundle.ui-testing
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "16.0"
|
||||||
|
sources:
|
||||||
|
- path: Tests/AdaptiveFixtureUITests
|
||||||
|
dependencies:
|
||||||
|
- target: AdaptiveSwiftUIFixture
|
||||||
|
- target: AdaptiveUIKitFixture
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.gstack.iosqa.adaptive.uitests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
CODE_SIGNING_ALLOWED: NO
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
AdaptiveFixtureUITests:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
AdaptiveSwiftUIFixture: all
|
||||||
|
AdaptiveUIKitFixture: all
|
||||||
|
AdaptiveFixtureUITests: [test]
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- AdaptiveFixtureUITests
|
||||||
|
environmentVariables:
|
||||||
|
GSTACK_IOS_QA_FLOW_PATH: $(GSTACK_IOS_QA_FLOW_PATH_VALUE)
|
||||||
|
GSTACK_IOS_QA_FLOW_JSON_BASE64: $(GSTACK_IOS_QA_FLOW_JSON_BASE64_VALUE)
|
||||||
|
GSTACK_IOS_QA_TARGET_KIND: $(GSTACK_IOS_QA_TARGET_KIND_VALUE)
|
||||||
|
|||||||
Reference in New Issue
Block a user