Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions ios-qa/executor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# XCUITest execution contract

This directory separates a logical iOS QA flow from where it runs. The same
semantic flow is routed to an iOS Simulator or a provisioned physical device.
It emits an `xcodebuild` argv specification; it does not invoke a shell, alter
signing, or provision a phone.

The checked-in XCUITest runner consumes inline base64 JSON from
`GSTACK_IOS_QA_FLOW_JSON_BASE64` on physical devices, because a device test
runner cannot open a path on the Mac. Simulator runs may also consume
`GSTACK_IOS_QA_FLOW_PATH`. It resolves each selector in
`selectorCandidates()` order, waits for hittability, performs the action, and
evaluates the optional post-action verification. A runner must return a
blocked/unsupported step result rather than falling back to screen coordinates.

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Runner consumers cannot receive the documented blocked/unsupported step result; the checked-in Swift runner reports these as XCTest failures. Document the actual failure channel, or add a structured result artifact before promising this contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/README.md, line 14:

<comment>Runner consumers cannot receive the documented `blocked`/`unsupported` step result; the checked-in Swift runner reports these as XCTest failures. Document the actual failure channel, or add a structured result artifact before promising this contract.</comment>

<file context>
@@ -0,0 +1,23 @@
+`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:
</file context>
Fix with cubic


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,

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Clients following this output contract may assume fields that are absent for JSON/file errors and flow-version or target-kind failures. Describe remediation and stepId as conditional, matching the actual result variants.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/README.md, line 22:

<comment>Clients following this output contract may assume fields that are absent for JSON/file errors and flow-version or target-kind failures. Describe `remediation` and `stepId` as conditional, matching the actual result variants.</comment>

<file context>
@@ -0,0 +1,23 @@
+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.
</file context>
Fix with cubic

`blocked` with remediation, or `unsupported` with the offending step id.
24 changes: 24 additions & 0 deletions ios-qa/executor/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bun
import { readFileSync } from 'fs';
import { buildXCUITestPlan } from './xcuitest-plan';
import type { IOSQAFlow, IOSQATarget, XCUITestRunnerConfig } from './contract';

function usage(): never {
console.error('usage: bun ios-qa/executor/cli.ts <flow.json> <target.json> <runner.json>');
process.exit(2);
}

const [, , flowPath, targetPath, runnerPath] = process.argv;
if (!flowPath || !targetPath || !runnerPath) usage();

try {
const flow = JSON.parse(readFileSync(flowPath, 'utf8')) as IOSQAFlow;
const target = JSON.parse(readFileSync(targetPath, 'utf8')) as IOSQATarget;
const runner = JSON.parse(readFileSync(runnerPath, 'utf8')) as XCUITestRunnerConfig;
const result = buildXCUITestPlan(flow, target, runner, flowPath);
console.log(JSON.stringify(result, null, 2));
if (result.status !== 'ready') process.exitCode = 1;
} catch (error) {
console.error(JSON.stringify({ status: 'blocked', reason: error instanceof Error ? error.message : String(error) }));

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Malformed JSON or an unexpected input shape produces a blocked object without the required remediation, so consumers cannot safely treat all CLI output as PlanResult. Include remediation in this fallback result to preserve the executor contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/cli.ts, line 22:

<comment>Malformed JSON or an unexpected input shape produces a `blocked` object without the required `remediation`, so consumers cannot safely treat all CLI output as `PlanResult`. Include remediation in this fallback result to preserve the executor contract.</comment>

<file context>
@@ -0,0 +1,24 @@
+  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;
+}
</file context>
Fix with cubic

process.exitCode = 1;
}
77 changes: 77 additions & 0 deletions ios-qa/executor/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export type IOSQATarget =
| { kind: 'simulator'; udid: string }
| { kind: 'device'; udid: string };

export type ElementRole =
| 'button'
| 'cell'
| 'link'
| 'navigationBar'
| 'secureTextField'
| 'staticText'
| 'switch'
| 'textField';

export interface ElementSelector {
/** Stable accessibility identifiers are always attempted before fallback fields. */
identifier?: string;
label?: string;
role?: ElementRole;
}

export type Verification =
| { kind: 'exists'; selector: ElementSelector; timeoutMs?: number }
| { kind: 'notExists'; selector: ElementSelector; timeoutMs?: number }
| { kind: 'labelEquals'; selector: ElementSelector; value: string; timeoutMs?: number };

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: labelEquals.timeoutMs does not wait for the label to reach the expected value; it only bounds element lookup. Post-action checks can therefore fail on normal asynchronous UI updates, so the runner should poll the label until the timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/contract.ts, line 25:

<comment>`labelEquals.timeoutMs` does not wait for the label to reach the expected value; it only bounds element lookup. Post-action checks can therefore fail on normal asynchronous UI updates, so the runner should poll the label until the timeout.</comment>

<file context>
@@ -0,0 +1,77 @@
+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 =
</file context>
Fix with cubic


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 }

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Selector-based swipes cannot configure their resolution timeout even though the runner supports it. Adding timeoutMs?: number keeps swipe behavior consistent with the other selector-based actions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/contract.ts, line 31:

<comment>Selector-based swipes cannot configure their resolution timeout even though the runner supports it. Adding `timeoutMs?: number` keeps swipe behavior consistent with the other selector-based actions.</comment>

<file context>
@@ -0,0 +1,77 @@
+  | { 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 };
+
</file context>
Fix with cubic

| { id: string; action: 'wait'; verification: Verification };

export interface IOSQAFlow {
version: 1;
name: string;
bundleIdentifier?: string;

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A flow without bundleIdentifier is accepted as ready but the XCUITest runner always blocks it at execution time. Make this field required and validate malformed JSON in buildXCUITestPlan() so readiness reflects executability.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/contract.ts, line 37:

<comment>A flow without `bundleIdentifier` is accepted as `ready` but the XCUITest runner always blocks it at execution time. Make this field required and validate malformed JSON in `buildXCUITestPlan()` so readiness reflects executability.</comment>

<file context>
@@ -0,0 +1,77 @@
+export interface IOSQAFlow {
+  version: 1;
+  name: string;
+  bundleIdentifier?: string;
+  steps: FlowStep[];
+}
</file context>
Fix with cubic

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;
}
49 changes: 49 additions & 0 deletions ios-qa/executor/examples/swiftui-gallery-flow.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"version": 1,
"name": "swiftui-gallery-smoke",
"bundleIdentifier": "com.gstack.iosqa.adaptive.swiftui",
"steps": [
{ "id": "launch", "action": "launch" },
{
"id": "increment",
"action": "tap",
"selector": { "identifier": "swiftui.increment", "label": "Increment", "role": "button" },
"verify": {
"kind": "labelEquals",
"selector": { "identifier": "swiftui.count", "role": "staticText" },
"value": "Count: 1"
}
},
{
"id": "search",
"action": "typeText",
"selector": { "identifier": "swiftui.search", "label": "Search terms", "role": "textField" },
"text": "adaptive",
"verify": {
"kind": "labelEquals",
"selector": { "identifier": "swiftui.echo", "role": "staticText" },
"value": "Echo: adaptive"
}
},
{
"id": "open-sheet",
"action": "tap",
"selector": { "identifier": "swiftui.sheet.open", "role": "button" },
"verify": {
"kind": "exists",
"selector": { "identifier": "swiftui.sheet.content" },
"timeoutMs": 2000
}
},
{
"id": "close-sheet",
"action": "tap",
"selector": { "identifier": "swiftui.sheet.done", "role": "button" },
"verify": {
"kind": "notExists",
"selector": { "identifier": "swiftui.sheet.content" },
"timeoutMs": 2000
}
}
]
}
83 changes: 83 additions & 0 deletions ios-qa/executor/xcuitest-plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test';
import type { IOSQAFlow, XCUITestRunnerConfig } from './contract';
import { buildXCUITestPlan, selectorCandidates } from './xcuitest-plan';

const runner: XCUITestRunnerConfig = {
projectPath: '/tmp/GStackIOSQARunner.xcodeproj',
scheme: 'GStackIOSQARunner',
testIdentifier: 'GStackIOSQARunnerUITests/FlowTests/testFlow',
};

const flow: IOSQAFlow = {
version: 1,
name: 'login',
steps: [
{ id: 'launch', action: 'launch' },
{ id: 'email', action: 'typeText', selector: { identifier: 'login.email', label: 'Email', role: 'textField' }, text: 'qa@example.com' },
{ id: 'submit', action: 'tap', selector: { identifier: 'login.submit', label: 'Sign in', role: 'button' }, verify: { kind: 'exists', selector: { identifier: 'home.title' } } },
],
};

describe('buildXCUITestPlan', () => {
test('orders stable identifiers before labels and preserves role narrowing', () => {
expect(selectorCandidates({ identifier: 'cart.checkout', label: 'Checkout', role: 'button' })).toEqual([
{ strategy: 'identifier', value: 'cart.checkout', role: 'button' },
{ strategy: 'label', value: 'Checkout', role: 'button' },
]);
});

test.each([
[{ kind: 'simulator', udid: 'SIM-1' } as const, 'simulator', 'iOS Simulator'],
[{ kind: 'device', udid: 'PHONE-1' } as const, 'device', 'iOS'],
])('routes %s through the same semantic flow contract', (target, kind, platform) => {
const result = buildXCUITestPlan(flow, target, runner, './flow.json');
expect(result.status).toBe('ready');
if (result.status !== 'ready') return;
expect(result.plan.target.kind).toBe(kind);
expect(result.plan.command.args).toContain(`platform=${platform},id=${target.udid}`);
expect(result.plan.command.args).toContain(`-only-testing:${runner.testIdentifier}`);
expect(result.plan.command.args).toContain(`GSTACK_IOS_QA_TARGET_KIND_VALUE=${kind}`);
expect(result.plan.command.env.GSTACK_IOS_QA_TARGET_KIND).toBe(kind);
expect(JSON.parse(Buffer.from(result.plan.command.env.GSTACK_IOS_QA_FLOW_JSON_BASE64, 'base64').toString('utf8'))).toMatchObject({
version: 1,
name: 'login',
});
expect(result.plan.command.args.some((arg) => arg.startsWith('GSTACK_IOS_QA_FLOW_JSON_BASE64_VALUE='))).toBe(true);
expect(result.plan.flow.steps[1]).toMatchObject({ timeoutMs: 10_000 });
expect(result.plan.capabilities.coordinateTaps).toBe(false);
expect(result.plan.capabilities.semanticSelectors).toEqual(['identifier', 'role', 'label']);
});

test('returns unsupported instead of guessing a coordinate for an unresolvable selector', () => {
const bad: IOSQAFlow = { ...flow, steps: [{ id: 'mystery', action: 'tap', selector: { role: 'button' } }] };
expect(buildXCUITestPlan(bad, { kind: 'simulator', udid: 'SIM-1' }, runner, './flow.json')).toEqual({
status: 'unsupported',
reason: 'selector needs an accessibility identifier or label',
stepId: 'mystery',
});
});

test('blocks before execution when runner configuration is ambiguous', () => {
const result = buildXCUITestPlan(flow, { kind: 'device', udid: 'PHONE-1' }, { ...runner, workspacePath: '/tmp/a.xcworkspace' }, './flow.json');
expect(result).toMatchObject({ status: 'blocked', reason: 'runner needs exactly one projectPath or workspacePath' });
});

test('builds argv without shell interpolation for physical devices', () => {
const result = buildXCUITestPlan(flow, { kind: 'device', udid: 'PHONE 1; touch /tmp/pwned' }, runner, './flow.json');
expect(result.status).toBe('ready');
if (result.status !== 'ready') return;
expect(result.plan.command.executable).toBe('xcodebuild');
expect(result.plan.command.args).toContain('platform=iOS,id=PHONE 1; touch /tmp/pwned');
expect(result.plan.command.args).not.toContain('touch');
});

test('reports malformed JSON actions and target kinds as unsupported', () => {
const badAction = { ...flow, steps: [{ id: 'bad', action: 'coordinateTap', x: 10, y: 20 }] } as unknown as IOSQAFlow;
expect(buildXCUITestPlan(badAction, { kind: 'simulator', udid: 'SIM-1' }, runner, './flow.json')).toMatchObject({
status: 'unsupported', reason: 'action coordinateTap is unsupported', stepId: 'bad',
});
expect(buildXCUITestPlan(flow, { kind: 'watch' as 'device', udid: 'WATCH-1' }, runner, './flow.json')).toMatchObject({
status: 'unsupported', reason: 'target kind watch is unsupported',
});
});
});
113 changes: 113 additions & 0 deletions ios-qa/executor/xcuitest-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { resolve } from 'path';

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Physical-device plans now bypass the repository's required DebugBridge/CoreDevice harness by routing device targets through XCUITest. Limit this backend to simulators and route physical devices through the existing harness.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/xcuitest-plan.ts, line 53:

<comment>Physical-device plans now bypass the repository's required DebugBridge/CoreDevice harness by routing `device` targets through XCUITest. Limit this backend to simulators and route physical devices through the existing harness.</comment>

<file context>
@@ -0,0 +1,113 @@
+  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` };
+  }
</file context>
Fix with cubic

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Malformed JSON can escape the structured PlanResult contract or be marked ready because required action/verification fields are trusted from TypeScript types after unchecked JSON casts. Add runtime schema validation before accessing fields or constructing the plan.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/xcuitest-plan.ts, line 71:

<comment>Malformed JSON can escape the structured `PlanResult` contract or be marked `ready` because required action/verification fields are trusted from TypeScript types after unchecked JSON casts. Add runtime schema validation before accessing fields or constructing the plan.</comment>

<file context>
@@ -0,0 +1,113 @@
+      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);
</file context>
Fix with cubic

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[] {

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: selectorCandidates is dead in production, while the Swift runner duplicates its ordering independently, so the two contracts can silently drift. Remove the unused export/documentation claim or add a shared conformance mechanism that actually governs runner behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/xcuitest-plan.ts, line 20:

<comment>`selectorCandidates` is dead in production, while the Swift runner duplicates its ordering independently, so the two contracts can silently drift. Remove the unused export/documentation claim or add a shared conformance mechanism that actually governs runner behavior.</comment>

<file context>
@@ -0,0 +1,113 @@
+}
+
+/** 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 });
</file context>
Fix with cubic

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.' };

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: buildXCUITestPlan crashes with a TypeError instead of returning a clean validation error when the JSON input is missing flow.name, target.udid, config.scheme, or config.testIdentifier. The function calls .trim() on these values assuming they're strings, but JSON.parse can produce undefined or null for missing/omitted fields. Since the CLI reads JSON files directly, a malformed flow, target, or runner config will throw an uncaught runtime exception instead of surfacing a useful blocked/unsupported result.

Guard each .trim() call with a truthiness check (e.g. if (!flow.name || !flow.name.trim())) so the function returns a structured error instead of crashing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/xcuitest-plan.ts, line 56:

<comment>`buildXCUITestPlan` crashes with a `TypeError` instead of returning a clean validation error when the JSON input is missing `flow.name`, `target.udid`, `config.scheme`, or `config.testIdentifier`. The function calls `.trim()` on these values assuming they're strings, but `JSON.parse` can produce `undefined` or `null` for missing/omitted fields. Since the CLI reads JSON files directly, a malformed flow, target, or runner config will throw an uncaught runtime exception instead of surfacing a useful `blocked`/`unsupported` result.

Guard each `.trim()` call with a truthiness check (e.g. `if (!flow.name || !flow.name.trim())`) so the function returns a structured error instead of crashing.</comment>

<file context>
@@ -0,0 +1,113 @@
+  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.` };
</file context>
Fix with cubic

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) {

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: A whitespace-only project or workspace path is treated as configured, producing a ready plan that xcodebuild cannot use. Apply the same trimmed-empty validation used for the other required strings.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/xcuitest-plan.ts, line 59:

<comment>A whitespace-only project or workspace path is treated as configured, producing a `ready` plan that `xcodebuild` cannot use. Apply the same trimmed-empty validation used for the other required strings.</comment>

<file context>
@@ -0,0 +1,113 @@
+  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.' };
+  }
</file context>
Suggested change
if (!!config.projectPath === !!config.workspacePath) {
if (!!config.projectPath?.trim() === !!config.workspacePath?.trim()) {
Fix with cubic

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)) {

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Swipe step direction values are not validated. A JSON flow like {"id":"s","action":"swipe","direction":"diagonal"} passes buildXCUITestPlan without error and is embedded in the plan. The downstream XCUITest runner would receive an unhandled direction value. Since the flow is JSON-loaded, there's no compile-time protection against typos like "diagonal", "vertical", or "".

Add a check inside the step-validation loop for swipe steps: if step.action === 'swipe', verify step.direction is one of the four allowed values and return unsupported if not.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/xcuitest-plan.ts, line 67:

<comment>Swipe step `direction` values are not validated. A JSON flow like `{"id":"s","action":"swipe","direction":"diagonal"}` passes `buildXCUITestPlan` without error and is embedded in the plan. The downstream XCUITest runner would receive an unhandled direction value. Since the flow is JSON-loaded, there's no compile-time protection against typos like `"diagonal"`, `"vertical"`, or `""`.

Add a check inside the step-validation loop for swipe steps: if `step.action === 'swipe'`, verify `step.direction` is one of the four allowed values and return `unsupported` if not.</comment>

<file context>
@@ -0,0 +1,113 @@
+  }
+
+  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 };
+    }
</file context>
Fix with cubic

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}`,

@cubic-dev-ai cubic-dev-ai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Test credentials can be exposed in process arguments and plan logs because the complete flow, including typeText values, is only base64-encoded. Use a secret-safe runtime transport or references resolved at execution, and redact payloads from emitted plans.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/executor/xcuitest-plan.ts, line 89:

<comment>Test credentials can be exposed in process arguments and plan logs because the complete flow, including `typeText` values, is only base64-encoded. Use a secret-safe runtime transport or references resolved at execution, and redact payloads from emitted plans.</comment>

<file context>
@@ -0,0 +1,113 @@
+  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}`,
+  );
</file context>
Fix with cubic

`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,
},
};
}
Loading
Loading