From 466b1ec7443185e63ed1a9e2c1591f0891a70e3e Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 14:19:14 -0700 Subject: [PATCH 1/2] 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 0000000000..cea09c549d --- /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 0000000000..d96fe365a4 --- /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 0000000000..4708d697d6 --- /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 0000000000..2e1d42d76c --- /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 0000000000..56c0dc96b4 --- /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 0000000000..d9df62ed23 --- /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, + }, + }; +} From c2ac1f32c08d68ef8b48540c9f2686395a651f6a Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 14:19:14 -0700 Subject: [PATCH 2/2] feat(ios-qa): adaptive XCUITest runner that reaches nested controls and verifies taps JSON-driven XCUITest runner plus SwiftUI/UIKit fixture apps that drive a real app by bundle id. The runner resolves the actual switch inside a SwiftUI Toggle wrapper, and when a center tap misses the full-width row it retries on the control's trailing edge via an element-anchored normalized offset (adaptive, not a raw screen coordinate), then verifies the switch value actually changed. A tap that silently does nothing is now reported as an interaction failure, not a false product defect. Validated on the simulator and on a physical iPhone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AdaptiveSwiftUIFixtureApp.swift | 79 ++++++ .../Sources/AdaptiveSwiftUIFixture/Info.plist | 30 ++ .../AdaptiveUIKitFixtureApp.swift | 85 ++++++ .../Sources/AdaptiveUIKitFixture/Info.plist | 30 ++ .../AdaptiveFixtureUITests.swift | 56 ++++ .../GStackFlowRunnerUITests.swift | 263 ++++++++++++++++++ test/fixtures/ios-qa/FixtureApp/project.yml | 70 +++++ 7 files changed, 613 insertions(+) create mode 100644 test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/AdaptiveSwiftUIFixtureApp.swift create mode 100644 test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/Info.plist create mode 100644 test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/AdaptiveUIKitFixtureApp.swift create mode 100644 test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/Info.plist create mode 100644 test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/AdaptiveFixtureUITests.swift create mode 100644 test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/GStackFlowRunnerUITests.swift diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/AdaptiveSwiftUIFixtureApp.swift b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/AdaptiveSwiftUIFixtureApp.swift new file mode 100644 index 0000000000..63cfbb60bd --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/AdaptiveSwiftUIFixtureApp.swift @@ -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 } + } + } + } +} diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/Info.plist b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/Info.plist new file mode 100644 index 0000000000..4dae2b7d79 --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + SwiftUI QA Gallery + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/AdaptiveUIKitFixtureApp.swift b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/AdaptiveUIKitFixtureApp.swift new file mode 100644 index 0000000000..27560e47af --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/AdaptiveUIKitFixtureApp.swift @@ -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 } +} diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/Info.plist b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/Info.plist new file mode 100644 index 0000000000..1adfde6d78 --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + UIKit QA Gallery + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/AdaptiveFixtureUITests.swift b/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/AdaptiveFixtureUITests.swift new file mode 100644 index 0000000000..50feb2a8c9 --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/AdaptiveFixtureUITests.swift @@ -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) + } +} diff --git a/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/GStackFlowRunnerUITests.swift b/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/GStackFlowRunnerUITests.swift new file mode 100644 index 0000000000..6f8ce41247 --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/GStackFlowRunnerUITests.swift @@ -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 ?? "")") + } + 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 + } +} diff --git a/test/fixtures/ios-qa/FixtureApp/project.yml b/test/fixtures/ios-qa/FixtureApp/project.yml index 35906f7b23..e1bfd04cc5 100644 --- a/test/fixtures/ios-qa/FixtureApp/project.yml +++ b/test/fixtures/ios-qa/FixtureApp/project.yml @@ -47,3 +47,73 @@ targets: SWIFT_VERSION: "5.9" IPHONEOS_DEPLOYMENT_TARGET: "16.0" 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)