mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
885 lines
34 KiB
TypeScript
885 lines
34 KiB
TypeScript
// Real-device tests. The lightweight CoreDevice checks run with
|
|
// GSTACK_HAS_IOS_DEVICE=1; the signing/install/interaction smoke test has the
|
|
// separate, explicit GSTACK_IOS_DEVICE_DEPLOY=1 opt-in.
|
|
//
|
|
// Runs only when:
|
|
// - An iPhone is connected via USB and reachable through CoreDevice
|
|
// - The iPhone is paired (user has tapped "Trust" on the trust dialog)
|
|
// - Developer Mode is enabled on the iPhone (Settings → Privacy → Developer Mode)
|
|
//
|
|
// What it actually exercises:
|
|
// 1. devicectl can list the device (verifies CoreDevice agent is reachable)
|
|
// 2. devicectl can list installed apps (verifies pairing + DDI is loaded)
|
|
// 3. devicectl can list running processes (verifies the management surface)
|
|
// 4. The fixture iOS SPM package builds with `swift build` for iOS target
|
|
// (verifies the templates compile against the iOS SDK, not just macOS)
|
|
//
|
|
// GSTACK_IOS_DEVICE_DEPLOY=1 additionally generates the fixture Xcode project,
|
|
// signs it with GSTACK_IOS_DEVELOPMENT_TEAM + GSTACK_IOS_BUNDLE_ID, installs
|
|
// and launches it, then proves screenshot/elements/tap through the real daemon.
|
|
// It remains skipped in normal CI because signing and a paired iPhone are
|
|
// intentionally machine-specific.
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { CAPTURE_LONG_MS } from './helpers/eval-budgets';
|
|
import { spawnSync } from 'child_process';
|
|
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { startDaemon, type RunningDaemon } from '../ios-qa/daemon/src/index';
|
|
import { startTunnelKeepalive } from '../ios-qa/daemon/src/devicectl';
|
|
import { bootstrapTunnel } from '../ios-qa/daemon/src/tunnel-bootstrap';
|
|
import type { DeviceTunnel } from '../ios-qa/daemon/src/proxy';
|
|
|
|
const ROOT = join(import.meta.dir, '..');
|
|
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
|
|
|
|
const HAS_DEVICE = process.env.GSTACK_HAS_IOS_DEVICE === '1';
|
|
const DEPLOY_TO_DEVICE = process.env.GSTACK_IOS_DEVICE_DEPLOY === '1';
|
|
const describeIfDevice = HAS_DEVICE ? describe : describe.skip;
|
|
const testIfDeploy = DEPLOY_TO_DEVICE ? test : test.skip;
|
|
|
|
interface DeviceListEntry {
|
|
identifier: string;
|
|
state: string; // "available" | "available (pairing)" | "unavailable" | ...
|
|
name: string;
|
|
model: string;
|
|
platform: string;
|
|
transport: string;
|
|
paired: boolean;
|
|
}
|
|
|
|
interface DeviceElement {
|
|
identifier?: string;
|
|
label?: string;
|
|
value?: string;
|
|
frame?: { x: number; y: number; w: number; h: number };
|
|
}
|
|
|
|
interface StateSnapshot {
|
|
_app_build_id?: string;
|
|
_accessor_hash?: string;
|
|
keys?: Record<string, unknown>;
|
|
}
|
|
|
|
function listDevices(): DeviceListEntry[] {
|
|
// devicectl JSON output requires --json-output to a path. Use a tempfile.
|
|
const tmp = `/tmp/devicectl-list-${process.pid}-${Date.now()}.json`;
|
|
try {
|
|
const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
|
|
stdio: 'pipe',
|
|
timeout: 30_000,
|
|
});
|
|
if (r.status !== 0) return [];
|
|
const raw = readFileSync(tmp, 'utf-8');
|
|
const obj = JSON.parse(raw);
|
|
return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string; pairingState?: string; transportType?: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string; platform?: string } }) => ({
|
|
identifier: d.identifier,
|
|
state: d.connectionProperties?.tunnelState ?? 'unknown',
|
|
name: d.deviceProperties?.name ?? 'unknown',
|
|
model: d.hardwareProperties?.productType ?? 'unknown',
|
|
platform: d.hardwareProperties?.platform ?? 'unknown',
|
|
transport: d.connectionProperties?.transportType ?? '',
|
|
paired: d.connectionProperties?.pairingState === 'paired',
|
|
}));
|
|
} catch {
|
|
return [];
|
|
} finally {
|
|
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
function isAvailableIPhone(device: DeviceListEntry): boolean {
|
|
const state = device.state.trim().toLowerCase();
|
|
const available = state === 'connected'
|
|
|| state.startsWith('available')
|
|
|| (state === 'disconnected' && device.transport.trim().toLowerCase() === 'wired');
|
|
return available
|
|
&& device.paired
|
|
&& device.platform.toLowerCase() === 'ios'
|
|
&& device.model.toLowerCase().startsWith('iphone');
|
|
}
|
|
|
|
function isPaired(udid: string): boolean {
|
|
// devicectl device info processes returns a clean exit when paired.
|
|
const tmp = `/tmp/devicectl-info-${process.pid}-${Date.now()}.json`;
|
|
const r = spawnSync('xcrun', [
|
|
'devicectl', 'device', 'info', 'processes',
|
|
'-d', udid,
|
|
'--json-output', tmp,
|
|
], { stdio: 'pipe', timeout: 30_000 });
|
|
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
// Pair-required errors surface on stderr with "must be paired" or
|
|
// CoreDeviceError 2. Treat any non-zero exit as not-paired.
|
|
return r.status === 0;
|
|
}
|
|
|
|
function requireDeployEnv(name: 'GSTACK_IOS_DEVELOPMENT_TEAM' | 'GSTACK_IOS_BUNDLE_ID'): string {
|
|
const value = process.env[name]?.trim();
|
|
if (!value) {
|
|
throw new Error(`${name} is required when GSTACK_IOS_DEVICE_DEPLOY=1`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function runChecked(
|
|
command: string,
|
|
args: string[],
|
|
opts: { cwd?: string; timeout?: number } = {},
|
|
): string {
|
|
const result = spawnSync(command, args, {
|
|
cwd: opts.cwd,
|
|
env: process.env,
|
|
stdio: 'pipe',
|
|
timeout: opts.timeout ?? 60_000,
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
const output = `${result.stdout?.toString() ?? ''}${result.stderr?.toString() ?? ''}`;
|
|
if (result.error || result.status !== 0) {
|
|
const tail = output.split('\n').slice(-120).join('\n');
|
|
throw new Error([
|
|
`${command} ${args.join(' ')} failed (${result.error?.message ?? `exit ${result.status}`})`,
|
|
tail,
|
|
].filter(Boolean).join('\n'));
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function daemonJson<T>(
|
|
baseURL: string,
|
|
path: string,
|
|
init: RequestInit = {},
|
|
): Promise<{ status: number; body: T; raw: string }> {
|
|
const response = await fetch(`${baseURL}${path}`, {
|
|
...init,
|
|
signal: AbortSignal.timeout(60_000),
|
|
});
|
|
const raw = await response.text();
|
|
let body: T;
|
|
try {
|
|
body = JSON.parse(raw) as T;
|
|
} catch {
|
|
throw new Error(`${init.method ?? 'GET'} ${path} returned non-JSON HTTP ${response.status}: ${raw.slice(0, 500)}`);
|
|
}
|
|
return { status: response.status, body, raw };
|
|
}
|
|
|
|
function findElement(elements: DeviceElement[], identifier: string): DeviceElement | undefined {
|
|
return elements.find((element) =>
|
|
element.identifier === identifier
|
|
&& (element.frame?.w ?? 0) > 0
|
|
&& (element.frame?.h ?? 0) > 0,
|
|
);
|
|
}
|
|
|
|
type DeviceElementPredicate = (element: DeviceElement) => boolean;
|
|
|
|
interface DeviceViewport {
|
|
w: number;
|
|
h: number;
|
|
}
|
|
|
|
function isInsideViewport(element: DeviceElement, viewport: DeviceViewport): boolean {
|
|
const frame = element.frame;
|
|
if (!frame || frame.w <= 0 || frame.h <= 0) return false;
|
|
const centerX = frame.x + frame.w / 2;
|
|
const centerY = frame.y + frame.h / 2;
|
|
return centerX >= 0 && centerX <= viewport.w && centerY >= 0 && centerY <= viewport.h;
|
|
}
|
|
|
|
async function readDeviceElements(baseURL: string): Promise<DeviceElement[]> {
|
|
const result = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
|
|
if (result.status !== 200 || !Array.isArray(result.body.elements)) {
|
|
throw new Error(`GET /elements failed with HTTP ${result.status}: ${result.raw.slice(0, 500)}`);
|
|
}
|
|
return result.body.elements;
|
|
}
|
|
|
|
async function waitForDeviceElement(
|
|
baseURL: string,
|
|
predicate: DeviceElementPredicate,
|
|
description: string,
|
|
options: {
|
|
condition?: DeviceElementPredicate;
|
|
tappableIn?: DeviceViewport;
|
|
timeoutMs?: number;
|
|
} = {},
|
|
): Promise<DeviceElement> {
|
|
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
|
|
let lastElements: DeviceElement[] = [];
|
|
while (Date.now() < deadline) {
|
|
lastElements = await readDeviceElements(baseURL);
|
|
const match = lastElements.find((element) =>
|
|
predicate(element)
|
|
&& (options.condition?.(element) ?? true)
|
|
&& (!options.tappableIn || isInsideViewport(element, options.tappableIn)),
|
|
);
|
|
if (match) return match;
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
}
|
|
const visible = lastElements
|
|
.filter((element) => element.identifier || element.label)
|
|
.slice(0, 80)
|
|
.map((element) => element.identifier ?? element.label)
|
|
.join(', ');
|
|
throw new Error(`timed out waiting for ${description}; last elements: ${visible}`);
|
|
}
|
|
|
|
async function tapDeviceElement(
|
|
baseURL: string,
|
|
sessionId: string,
|
|
element: DeviceElement,
|
|
): Promise<void> {
|
|
const frame = element.frame;
|
|
if (!frame) throw new Error('cannot tap an element without a frame');
|
|
const tapped = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/tap', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-session-id': sessionId,
|
|
},
|
|
body: JSON.stringify({
|
|
x: frame.x + frame.w / 2,
|
|
y: frame.y + frame.h / 2,
|
|
}),
|
|
});
|
|
if (tapped.status !== 200 || tapped.body.ok !== true) {
|
|
throw new Error(`tap failed with HTTP ${tapped.status}: ${tapped.raw.slice(0, 500)}`);
|
|
}
|
|
}
|
|
|
|
describeIfDevice('ios device path', () => {
|
|
test('devicectl lists at least one connected device', () => {
|
|
const devices = listDevices();
|
|
if (devices.length === 0) {
|
|
console.error('No CoreDevice-reachable iPhone. Connect via USB and unlock.');
|
|
}
|
|
expect(devices.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('one device reports as paired (DDI loaded, processes listable)', () => {
|
|
const devices = listDevices();
|
|
expect(devices.length).toBeGreaterThan(0);
|
|
const paired = devices.filter(d => isPaired(d.identifier));
|
|
if (paired.length === 0) {
|
|
const first = devices[0]!;
|
|
console.error([
|
|
`Device "${first.name}" (${first.model}, ${first.identifier})`,
|
|
`is connected but NOT paired. To pair:`,
|
|
` 1. Unlock the iPhone with passcode.`,
|
|
` 2. Run: xcrun devicectl manage pair --device ${first.identifier}`,
|
|
` 3. Tap "Trust" on the iPhone's trust dialog.`,
|
|
` 4. Open Settings → Privacy → Developer Mode and enable it (iOS 16+).`,
|
|
` 5. Restart the iPhone if prompted.`,
|
|
` 6. Re-run this test.`,
|
|
].join('\n'));
|
|
}
|
|
expect(paired.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('fixture iOS SDK and UIKit compile guards are available', () => {
|
|
// This is an environment + source-guard preflight. The explicit deployment
|
|
// test below performs the real signed iOS xcodebuild before installation.
|
|
const sdkPath = spawnSync('xcrun', ['--sdk', 'iphoneos', '--show-sdk-path'], { stdio: 'pipe', timeout: 30_000 });
|
|
if (sdkPath.status !== 0) {
|
|
console.error('iOS SDK not found. Install via Xcode.');
|
|
}
|
|
expect(sdkPath.status).toBe(0);
|
|
const sdk = sdkPath.stdout.toString().trim();
|
|
expect(sdk).toContain('iPhoneOS');
|
|
|
|
// SwiftPM cannot directly cross-build this UIKit package with the standalone
|
|
// host command, so keep the static guard assertion honest and narrowly named.
|
|
const fs = require('fs') as typeof import('fs');
|
|
const overlay = fs.readFileSync(
|
|
join(FIXTURE_PATH, 'Sources/DebugBridgeUI/DebugOverlay.swift'),
|
|
'utf-8',
|
|
);
|
|
// Sanity check: the UI module is correctly gated for iOS-only.
|
|
expect(overlay).toContain('#if DEBUG && canImport(UIKit)');
|
|
expect(overlay).toContain('#endif');
|
|
});
|
|
|
|
});
|
|
|
|
describe('ios device deployment (explicit opt-in)', () => {
|
|
testIfDeploy('generates, signs, installs, launches, and drives the fixture through the daemon', async () => {
|
|
const developmentTeam = requireDeployEnv('GSTACK_IOS_DEVELOPMENT_TEAM');
|
|
const bundleId = requireDeployEnv('GSTACK_IOS_BUNDLE_ID');
|
|
const devices = listDevices();
|
|
const device = devices.find((candidate) => isAvailableIPhone(candidate) && isPaired(candidate.identifier));
|
|
if (!device) {
|
|
const summary = devices.length > 0
|
|
? devices.map((d) => ` ${d.name} (${d.model}, ${d.platform}, ${d.identifier}): state=${d.state}, paired=${d.paired}`).join('\n')
|
|
: ' devicectl returned no devices';
|
|
throw new Error([
|
|
'GSTACK_IOS_DEVICE_DEPLOY=1 requires an available, paired iPhone; stale unavailable devices are never selected.',
|
|
summary,
|
|
].join('\n'));
|
|
}
|
|
|
|
const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-device-deploy-'));
|
|
const fixtureDir = join(workDir, 'FixtureApp');
|
|
const derivedData = join(workDir, 'DerivedData');
|
|
let daemon: RunningDaemon | undefined;
|
|
let keepalive: { stop: () => void } | undefined;
|
|
let sessionId: string | undefined;
|
|
|
|
try {
|
|
cpSync(FIXTURE_PATH, fixtureDir, { recursive: true });
|
|
|
|
// Exercise the same deterministic bootstrap that /ios-qa and /ios-sync
|
|
// install for users. This creates the app-owned typed accessor before
|
|
// XcodeGen discovers the fixture sources.
|
|
runChecked(join(ROOT, 'bin/gstack-ios-qa-regen'), [
|
|
'--app-source', join(fixtureDir, 'Sources/FixtureApp'),
|
|
'--bridge-dir', fixtureDir,
|
|
], { cwd: fixtureDir });
|
|
const generatedAccessor = join(
|
|
fixtureDir,
|
|
'Sources/FixtureApp/DebugBridgeGenerated/StateAccessor.swift',
|
|
);
|
|
expect(existsSync(generatedAccessor)).toBe(true);
|
|
expect(readFileSync(generatedAccessor, 'utf8')).toContain('enum FixtureAppStateAccessor');
|
|
|
|
runChecked('xcodegen', [
|
|
'generate',
|
|
'--spec', join(fixtureDir, 'project.yml'),
|
|
'--project', fixtureDir,
|
|
'--project-root', fixtureDir,
|
|
], { cwd: fixtureDir });
|
|
|
|
const projectPath = join(fixtureDir, 'FixtureApp.xcodeproj');
|
|
expect(existsSync(projectPath)).toBe(true);
|
|
|
|
runChecked('xcodebuild', [
|
|
'-project', projectPath,
|
|
'-scheme', 'FixtureApp',
|
|
'-configuration', 'Debug',
|
|
'-destination', `platform=iOS,id=${device.identifier}`,
|
|
'-derivedDataPath', derivedData,
|
|
'-allowProvisioningUpdates',
|
|
`DEVELOPMENT_TEAM=${developmentTeam}`,
|
|
`PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`,
|
|
'CODE_SIGN_STYLE=Automatic',
|
|
'build',
|
|
], { cwd: fixtureDir, timeout: 300_000 });
|
|
|
|
const appBundle = join(derivedData, 'Build/Products/Debug-iphoneos/FixtureApp.app');
|
|
expect(existsSync(appBundle)).toBe(true);
|
|
const builtBundleId = runChecked('/usr/libexec/PlistBuddy', [
|
|
'-c', 'Print :CFBundleIdentifier',
|
|
join(appBundle, 'Info.plist'),
|
|
]).trim();
|
|
expect(builtBundleId).toBe(bundleId);
|
|
|
|
runChecked('xcrun', [
|
|
'devicectl', 'device', 'install', 'app',
|
|
'--device', device.identifier,
|
|
appBundle,
|
|
], { timeout: 120_000 });
|
|
|
|
runChecked('xcrun', [
|
|
'devicectl', 'device', 'process', 'launch',
|
|
'--device', device.identifier,
|
|
'--terminate-existing',
|
|
bundleId,
|
|
], { timeout: 60_000 });
|
|
|
|
keepalive = startTunnelKeepalive(device.identifier);
|
|
const bootstrap = await bootstrapTunnel({
|
|
udid: device.identifier,
|
|
bundleId,
|
|
startupTimeoutMs: 30_000,
|
|
});
|
|
if (!bootstrap.ok) {
|
|
throw new Error(`daemon tunnel bootstrap failed: ${bootstrap.error}${bootstrap.detail ? ` (${bootstrap.detail})` : ''}`);
|
|
}
|
|
|
|
// The first provider call consumes the already-rotated bootstrap. Later
|
|
// calls perform a fresh bootstrap so the same daemon can recover after
|
|
// this app is relaunched and its in-memory bearer changes.
|
|
let pendingTunnel: DeviceTunnel | undefined = bootstrap.tunnel;
|
|
let tunnelProviderCalls = 0;
|
|
const provideTunnel = async (): Promise<DeviceTunnel | null> => {
|
|
tunnelProviderCalls += 1;
|
|
if (pendingTunnel) {
|
|
const first = pendingTunnel;
|
|
pendingTunnel = undefined;
|
|
return first;
|
|
}
|
|
const refreshed = await bootstrapTunnel({
|
|
udid: device.identifier,
|
|
bundleId,
|
|
startupTimeoutMs: 30_000,
|
|
});
|
|
if (!refreshed.ok) {
|
|
throw new Error(`daemon rebootstrap failed: ${refreshed.error}${refreshed.detail ? ` (${refreshed.detail})` : ''}`);
|
|
}
|
|
return refreshed.tunnel;
|
|
};
|
|
|
|
const started = await startDaemon({
|
|
loopbackPort: 0,
|
|
tailnetEnabled: false,
|
|
pidfilePath: join(workDir, 'daemon.pid'),
|
|
tunnelProvider: provideTunnel,
|
|
});
|
|
if ('error' in started) {
|
|
throw new Error(`daemon failed to start: ${started.error}${started.reason ? ` (${started.reason})` : ''}`);
|
|
}
|
|
daemon = started;
|
|
const baseURL = `http://127.0.0.1:${daemon.loopbackPort}`;
|
|
|
|
const initialState = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
|
expect(initialState.status).toBe(200);
|
|
expect(typeof initialState.body._app_build_id).toBe('string');
|
|
expect(initialState.body._app_build_id).not.toBe('unknown');
|
|
expect(initialState.body._app_build_id).not.toBe('uninitialized');
|
|
expect(initialState.body._accessor_hash).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(initialState.body._accessor_hash).not.toBe('uninitialized');
|
|
expect(Object.keys(initialState.body.keys ?? {}).sort()).toEqual([
|
|
'isLoggedIn',
|
|
'nickname',
|
|
'tapCounter',
|
|
'username',
|
|
]);
|
|
expect(initialState.body.keys?.nickname).toBeNull();
|
|
|
|
const screenshot = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
|
|
expect(screenshot.status).toBe(200);
|
|
expect(typeof screenshot.body.png_base64).toBe('string');
|
|
const png = Buffer.from(screenshot.body.png_base64!, 'base64');
|
|
expect(png.length).toBeGreaterThan(1_000);
|
|
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
|
|
const requiredIdentifiers = [
|
|
'primary-button',
|
|
'toolbar-actions-menu',
|
|
'open-detail-button',
|
|
'tab-controls',
|
|
'tab-inputs',
|
|
'tab-rows',
|
|
];
|
|
let elementsBefore: DeviceElement[] = [];
|
|
let identifiers = new Set<string>();
|
|
const elementsDeadline = Date.now() + 10_000;
|
|
while (Date.now() < elementsDeadline) {
|
|
const before = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
|
|
expect(before.status).toBe(200);
|
|
expect(Array.isArray(before.body.elements)).toBe(true);
|
|
elementsBefore = before.body.elements ?? [];
|
|
identifiers = new Set(
|
|
elementsBefore.map((element) => element.identifier).filter((value): value is string => Boolean(value)),
|
|
);
|
|
if (requiredIdentifiers.every((identifier) => identifiers.has(identifier))) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
expect(elementsBefore.length).toBeGreaterThan(30);
|
|
expect(requiredIdentifiers.filter((identifier) => !identifiers.has(identifier))).toEqual([]);
|
|
const appFrame = findElement(elementsBefore, 'fixture-tab-view')?.frame;
|
|
expect(appFrame).toBeDefined();
|
|
// Screenshot pixels and /tap coordinates must share UIKit's point
|
|
// space. A 3x PNG here recreates the original missed-tap bug.
|
|
expect(png.readUInt32BE(16)).toBe(appFrame!.w);
|
|
expect(png.readUInt32BE(20)).toBe(appFrame!.h);
|
|
|
|
const buttonBefore = findElement(elementsBefore, 'primary-button');
|
|
expect(buttonBefore).toBeDefined();
|
|
expect(typeof buttonBefore!.value).toBe('string');
|
|
|
|
const acquired = await daemonJson<{ session_id?: string }>(baseURL, '/session/acquire', { method: 'POST' });
|
|
expect(acquired.status).toBe(200);
|
|
expect(typeof acquired.body.session_id).toBe('string');
|
|
sessionId = acquired.body.session_id!;
|
|
|
|
const rejectedBooleanAsInteger = await daemonJson<{ error?: string }>(baseURL, '/state/tapCounter', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-session-id': sessionId,
|
|
},
|
|
body: JSON.stringify({ value: true }),
|
|
});
|
|
expect(rejectedBooleanAsInteger.status).toBe(400);
|
|
expect(rejectedBooleanAsInteger.body.error).toBe('type_mismatch');
|
|
|
|
const rejectedIntegerAsBoolean = await daemonJson<{ error?: string }>(baseURL, '/state/isLoggedIn', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-session-id': sessionId,
|
|
},
|
|
body: JSON.stringify({ value: 1 }),
|
|
});
|
|
expect(rejectedIntegerAsBoolean.status).toBe(400);
|
|
expect(rejectedIntegerAsBoolean.body.error).toBe('type_mismatch');
|
|
const afterRejectedCoercions = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
|
expect(afterRejectedCoercions.body.keys?.tapCounter).toBe(0);
|
|
expect(afterRejectedCoercions.body.keys?.isLoggedIn).toBe(false);
|
|
|
|
const wroteState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/tapCounter', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-session-id': sessionId,
|
|
},
|
|
body: JSON.stringify({ value: 7 }),
|
|
});
|
|
expect(wroteState.status).toBe(200);
|
|
expect(wroteState.body).toEqual({ ok: true });
|
|
const updatedState = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
|
expect(updatedState.status).toBe(200);
|
|
expect(updatedState.body.keys?.tapCounter).toBe(7);
|
|
|
|
const wroteOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-session-id': sessionId,
|
|
},
|
|
body: JSON.stringify({ value: 'Device' }),
|
|
});
|
|
expect(wroteOptional.status).toBe(200);
|
|
const optionalValue = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
|
expect(optionalValue.body.keys?.nickname).toBe('Device');
|
|
|
|
const clearedOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-session-id': sessionId,
|
|
},
|
|
body: JSON.stringify({ value: null }),
|
|
});
|
|
expect(clearedOptional.status).toBe(200);
|
|
const clearedValue = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
|
expect(clearedValue.body.keys?.nickname).toBeNull();
|
|
|
|
const restoredState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/restore', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-session-id': sessionId,
|
|
},
|
|
body: JSON.stringify(initialState.body),
|
|
});
|
|
expect(restoredState.status).toBe(200);
|
|
expect(restoredState.body).toEqual({ ok: true });
|
|
const afterRestore = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
|
expect(afterRestore.body.keys?.tapCounter).toBe(0);
|
|
expect(afterRestore.body.keys?.nickname).toBeNull();
|
|
|
|
const viewport = { w: appFrame!.w, h: appFrame!.h };
|
|
const byIdentifier = (identifier: string): DeviceElementPredicate =>
|
|
(element) => element.identifier === identifier;
|
|
const byLabel = (label: string): DeviceElementPredicate =>
|
|
(element) => element.label?.trim() === label;
|
|
|
|
const tapAndWaitForValueChange = async (
|
|
target: DeviceElementPredicate,
|
|
oracle: DeviceElementPredicate,
|
|
description: string,
|
|
): Promise<DeviceElement> => {
|
|
const before = await waitForDeviceElement(
|
|
baseURL,
|
|
oracle,
|
|
`${description} oracle before tap`,
|
|
{ condition: (element) => typeof element.value === 'string' },
|
|
);
|
|
const targetElement = await waitForDeviceElement(
|
|
baseURL,
|
|
target,
|
|
`${description} target`,
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId!, targetElement);
|
|
const after = await waitForDeviceElement(
|
|
baseURL,
|
|
oracle,
|
|
`${description} value change`,
|
|
{ condition: (element) => typeof element.value === 'string' && element.value !== before.value },
|
|
);
|
|
expect(after.value).not.toBe(before.value);
|
|
return after;
|
|
};
|
|
|
|
const firstInteger = (value: string | undefined): number | undefined => {
|
|
const match = value?.match(/-?\d+/);
|
|
return match ? Number(match[0]) : undefined;
|
|
};
|
|
|
|
const tapAndWaitForCountIncrement = async (
|
|
target: DeviceElementPredicate,
|
|
oracle: DeviceElementPredicate,
|
|
description: string,
|
|
): Promise<DeviceElement> => {
|
|
const before = await waitForDeviceElement(
|
|
baseURL,
|
|
oracle,
|
|
`${description} counter before tap`,
|
|
{ condition: (element) => firstInteger(element.value) !== undefined },
|
|
);
|
|
const beforeCount = firstInteger(before.value)!;
|
|
const targetElement = await waitForDeviceElement(
|
|
baseURL,
|
|
target,
|
|
`${description} target`,
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId!, targetElement);
|
|
const after = await waitForDeviceElement(
|
|
baseURL,
|
|
oracle,
|
|
`${description} exactly-once counter increment`,
|
|
{ condition: (element) => firstInteger(element.value) === beforeCount + 1 },
|
|
);
|
|
expect(firstInteger(after.value)).toBe(beforeCount + 1);
|
|
return after;
|
|
};
|
|
|
|
// SwiftUI button styles and both navigation-bar controls.
|
|
for (const identifier of [
|
|
'primary-button',
|
|
'bordered-button',
|
|
'plain-button',
|
|
'destructive-button',
|
|
'nav-refresh-button',
|
|
]) {
|
|
await tapAndWaitForCountIncrement(
|
|
byIdentifier(identifier),
|
|
byIdentifier(identifier),
|
|
identifier,
|
|
);
|
|
}
|
|
|
|
// Menu presentation and both menu commands. The menu's own value is the
|
|
// stable oracle after each transient command element disappears.
|
|
for (const commandIdentifier of ['menu-add-item', 'menu-archive-item']) {
|
|
const menuBefore = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('toolbar-actions-menu'),
|
|
'toolbar menu value',
|
|
{ condition: (element) => typeof element.value === 'string' },
|
|
);
|
|
const menu = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('toolbar-actions-menu'),
|
|
'toolbar actions menu',
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId, menu);
|
|
const command = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier(commandIdentifier),
|
|
commandIdentifier,
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId, command);
|
|
const beforeCounts = [...(menuBefore.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
|
|
expect(beforeCounts).toHaveLength(2);
|
|
const changedIndex = commandIdentifier === 'menu-add-item' ? 0 : 1;
|
|
const expectedCounts = beforeCounts.map((count, index) => count + (index === changedIndex ? 1 : 0));
|
|
const menuAfter = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('toolbar-actions-menu'),
|
|
`${commandIdentifier} exactly-once result`,
|
|
{
|
|
condition: (element) => {
|
|
const counts = [...(element.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
|
|
return counts.length === 2 && counts.every((count, index) => count === expectedCounts[index]);
|
|
},
|
|
},
|
|
);
|
|
const afterCounts = [...(menuAfter.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
|
|
expect(afterCounts).toEqual(expectedCounts);
|
|
}
|
|
|
|
// Push, interact with, and pop the explicit navigation destination.
|
|
const openDetail = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('open-detail-button'),
|
|
'open detail button',
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId, openDetail);
|
|
await waitForDeviceElement(baseURL, byIdentifier('detail-screen-title'), 'detail destination');
|
|
await tapAndWaitForCountIncrement(
|
|
byIdentifier('detail-action-button'),
|
|
byIdentifier('detail-action-button'),
|
|
'detail action button',
|
|
);
|
|
const back = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('detail-back-button'),
|
|
'detail back button',
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId, back);
|
|
await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'controls after detail pop');
|
|
|
|
// Tab navigation plus native toggle, stepper, segmented picker, text
|
|
// input/commit, and a UIKit UIButton.
|
|
const inputsTab = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('tab-inputs'),
|
|
'Inputs tab',
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId, inputsTab);
|
|
await waitForDeviceElement(baseURL, byIdentifier('harness-toggle'), 'Inputs controls');
|
|
await tapAndWaitForValueChange(
|
|
byIdentifier('harness-toggle'),
|
|
byIdentifier('harness-toggle'),
|
|
'toggle',
|
|
);
|
|
await tapAndWaitForCountIncrement(
|
|
byIdentifier('harness-stepper-Increment'),
|
|
byIdentifier('harness-stepper'),
|
|
'stepper increment',
|
|
);
|
|
const segmentTwo = await waitForDeviceElement(
|
|
baseURL,
|
|
byLabel('Two'),
|
|
'segmented picker option Two',
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId, segmentTwo);
|
|
const selectedTwo = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('harness-segmented-picker'),
|
|
'segmented picker selection',
|
|
{ condition: (element) => element.value?.includes('Two') === true },
|
|
);
|
|
expect(selectedTwo.value).toContain('Two');
|
|
|
|
const textField = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('harness-text-field'),
|
|
'text field',
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId, textField);
|
|
const typed = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/type', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-session-id': sessionId,
|
|
},
|
|
body: JSON.stringify({ text: 'device matrix' }),
|
|
});
|
|
expect(typed.status).toBe(200);
|
|
expect(typed.body).toMatchObject({ op: 'type', ok: true });
|
|
await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('harness-text-field'),
|
|
'typed text value',
|
|
{ condition: (element) => element.value === 'device matrix' },
|
|
);
|
|
await tapAndWaitForCountIncrement(
|
|
byIdentifier('commit-text-button'),
|
|
byIdentifier('commit-text-button'),
|
|
'text commit button',
|
|
);
|
|
// Commit clears FocusState; let the keyboard dismissal animation finish
|
|
// before choosing a scroll-view hit point for the UIKit control below.
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
const scrollInputs = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/swipe', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-session-id': sessionId,
|
|
},
|
|
body: JSON.stringify({ from_x: 200, from_y: 650, to_x: 200, to_y: 220 }),
|
|
});
|
|
expect(scrollInputs.status).toBe(200);
|
|
expect(scrollInputs.body).toMatchObject({ op: 'swipe', ok: true });
|
|
await tapAndWaitForCountIncrement(
|
|
byIdentifier('uikit-button'),
|
|
byIdentifier('uikit-button'),
|
|
'UIKit button',
|
|
);
|
|
|
|
// All four list rows and the row navigation-bar action.
|
|
const rowsTab = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('tab-rows'),
|
|
'Rows tab',
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId, rowsTab);
|
|
await waitForDeviceElement(baseURL, byIdentifier('row-alpha-button'), 'Rows list');
|
|
for (const row of ['alpha', 'bravo', 'charlie', 'delta']) {
|
|
await tapAndWaitForCountIncrement(
|
|
byIdentifier(`row-${row}-button`),
|
|
byIdentifier(`row-${row}-button`),
|
|
`${row} row`,
|
|
);
|
|
}
|
|
await tapAndWaitForCountIncrement(
|
|
byIdentifier('rows-toolbar-button'),
|
|
byIdentifier('rows-toolbar-button'),
|
|
'rows toolbar button',
|
|
);
|
|
|
|
const controlsTab = await waitForDeviceElement(
|
|
baseURL,
|
|
byIdentifier('tab-controls'),
|
|
'Controls tab',
|
|
{ tappableIn: viewport },
|
|
);
|
|
await tapDeviceElement(baseURL, sessionId, controlsTab);
|
|
await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'Controls tab restored');
|
|
|
|
// Keep the daemon alive while the app process gets a new boot token.
|
|
// The first proxied request must observe the stale bearer, invalidate
|
|
// only that tunnel, single-flight a fresh bootstrap, and retry once.
|
|
runChecked('xcrun', [
|
|
'devicectl', 'device', 'process', 'launch',
|
|
'--device', device.identifier,
|
|
'--terminate-existing',
|
|
bundleId,
|
|
], { timeout: 60_000 });
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
const afterRelaunch = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
|
|
expect(afterRelaunch.status).toBe(200);
|
|
expect(Buffer.from(afterRelaunch.body.png_base64 ?? '', 'base64').length).toBeGreaterThan(1_000);
|
|
expect(tunnelProviderCalls).toBe(2);
|
|
const stateAfterRelaunch = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
|
|
expect(stateAfterRelaunch.status).toBe(200);
|
|
expect(stateAfterRelaunch.body._accessor_hash).toBe(initialState.body._accessor_hash);
|
|
} finally {
|
|
if (daemon && sessionId) {
|
|
await daemonJson(`http://127.0.0.1:${daemon.loopbackPort}`, '/session/release', { method: 'POST' }).catch(() => undefined);
|
|
}
|
|
if (daemon) await daemon.close();
|
|
keepalive?.stop();
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
}, CAPTURE_LONG_MS);
|
|
});
|
|
|
|
// Always-on instructions if not paired. Surfaces actionable steps even when
|
|
// the test is opted in via env var but the device isn't ready.
|
|
if (HAS_DEVICE) {
|
|
const devices = listDevices();
|
|
const unpaired = devices.filter(d =>
|
|
d.platform.toLowerCase() === 'ios'
|
|
&& d.model.toLowerCase().startsWith('iphone')
|
|
&& !d.paired,
|
|
);
|
|
if (unpaired.length > 0) {
|
|
console.error('');
|
|
console.error('=== iOS DEVICE PAIRING REQUIRED ===');
|
|
for (const d of unpaired) {
|
|
console.error(` Device: ${d.name} (${d.model}, ${d.identifier})`);
|
|
console.error(` Status: ${d.state}`);
|
|
}
|
|
console.error(' Run: xcrun devicectl manage pair --device <UDID>');
|
|
console.error(' Then tap "Trust" on the iPhone.');
|
|
console.error('===================================');
|
|
console.error('');
|
|
}
|
|
}
|