mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-05 23:57:53 +02:00
Merge origin/main into /spec branch — retag v1.45.0.0 → v1.47.0.0
main moved to v1.46.0.0 (gstack v2 foundation, eval-first floor across 51 skills) while this branch was at v1.45.0.0. v1.46 also reserved v1.45.0.0 for the design daemon feature. Retag this branch's release v1.45.0.0 → v1.47.0.0 so it lands cleanly on top of main. Conflict resolutions: - VERSION: 1.47.0.0 (MINOR continues on top of main's 1.46.0.0; this branch is also a MINOR per scale-aware rules — new skill capability). - CHANGELOG: rewrite this branch's release header v1.45.0.0 → v1.47.0.0. Keep both main entries above main's older history. Adapts to main's eval-first floor (v1.46.0.0 test/skill-coverage-matrix.ts + test/skill-coverage-floor.test.ts): - Register /spec in SKILL_COVERAGE with 3 gate entries + 2 periodic. - Skill catalog grows 51 → 52. Floor 6/6 structural checks pass. - Catalog tokens: 4045 → 4116 (+71 for /spec, within v1.46's ≤7000 budget). - Trim spec frontmatter description to single-paragraph block form to respect v1.46's catalog-trim intent (was 14 lines / ~900 chars, now 5 lines / ~350 chars; routing prose stays in body sections). - 363/363 gate-tier tests pass across skill-coverage-floor (309) + skill-coverage-matrix (10) + skill-size-budget (3) + parity-suite (4) + spec-template-invariants (35) + spec-template-sync (2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
-89
@@ -2,17 +2,7 @@
|
||||
name: ios-qa
|
||||
preamble-tier: 3
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Live-device iOS QA for SwiftUI apps. Connects to a real iPhone via USB
|
||||
CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then
|
||||
runs a vision-driven agent loop: screenshot → analyze → decide → act →
|
||||
verify → repeat. All interaction happens via HTTP to an embedded
|
||||
StateServer in the app under test. Optionally exposes the device over
|
||||
Tailscale so remote agents (OpenClaw, Codex, any HTTP-capable agent) can
|
||||
run iOS QA from anywhere without touching the hardware.
|
||||
Use when asked to "ios qa", "test my iPhone app", "find bugs on the device",
|
||||
or "qa the iOS app". (gstack)
|
||||
Voice triggers (speech-to-text aliases): "iOS quality check", "test the iPhone app", "run iOS QA".
|
||||
description: Live-device iOS QA for SwiftUI apps. (gstack)
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
@@ -31,6 +21,21 @@ triggers:
|
||||
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
|
||||
<!-- Regenerate: bun run gen:skill-docs -->
|
||||
|
||||
|
||||
## When to invoke this skill
|
||||
|
||||
Connects to a real iPhone via USB
|
||||
CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then
|
||||
runs a vision-driven agent loop: screenshot → analyze → decide → act →
|
||||
verify → repeat. All interaction happens via HTTP to an embedded
|
||||
StateServer in the app under test. Optionally exposes the device over
|
||||
Tailscale so remote agents (OpenClaw, Codex, any HTTP-capable agent) can
|
||||
run iOS QA from anywhere without touching the hardware.
|
||||
Use when asked to "ios qa", "test my iPhone app", "find bugs on the device",
|
||||
or "qa the iOS app".
|
||||
|
||||
Voice triggers (speech-to-text aliases): "iOS quality check", "test the iPhone app", "run iOS QA".
|
||||
|
||||
## Preamble (run first)
|
||||
|
||||
```bash
|
||||
@@ -571,84 +576,7 @@ Applies to AskUserQuestion, user replies, and findings. AskUserQuestion Format i
|
||||
- User-turn override wins: if the current message asks for terse / no explanations / just the answer, skip this section.
|
||||
- Terse mode (EXPLAIN_LEVEL: terse): no glosses, no outcome-framing layer, shorter responses.
|
||||
|
||||
Jargon list, gloss on first use if the term appears:
|
||||
- idempotent
|
||||
- idempotency
|
||||
- race condition
|
||||
- deadlock
|
||||
- cyclomatic complexity
|
||||
- N+1
|
||||
- N+1 query
|
||||
- backpressure
|
||||
- memoization
|
||||
- eventual consistency
|
||||
- CAP theorem
|
||||
- CORS
|
||||
- CSRF
|
||||
- XSS
|
||||
- SQL injection
|
||||
- prompt injection
|
||||
- DDoS
|
||||
- rate limit
|
||||
- throttle
|
||||
- circuit breaker
|
||||
- load balancer
|
||||
- reverse proxy
|
||||
- SSR
|
||||
- CSR
|
||||
- hydration
|
||||
- tree-shaking
|
||||
- bundle splitting
|
||||
- code splitting
|
||||
- hot reload
|
||||
- tombstone
|
||||
- soft delete
|
||||
- cascade delete
|
||||
- foreign key
|
||||
- composite index
|
||||
- covering index
|
||||
- OLTP
|
||||
- OLAP
|
||||
- sharding
|
||||
- replication lag
|
||||
- quorum
|
||||
- two-phase commit
|
||||
- saga
|
||||
- outbox pattern
|
||||
- inbox pattern
|
||||
- optimistic locking
|
||||
- pessimistic locking
|
||||
- thundering herd
|
||||
- cache stampede
|
||||
- bloom filter
|
||||
- consistent hashing
|
||||
- virtual DOM
|
||||
- reconciliation
|
||||
- closure
|
||||
- hoisting
|
||||
- tail call
|
||||
- GIL
|
||||
- zero-copy
|
||||
- mmap
|
||||
- cold start
|
||||
- warm start
|
||||
- green-blue deploy
|
||||
- canary deploy
|
||||
- feature flag
|
||||
- kill switch
|
||||
- dead letter queue
|
||||
- fan-out
|
||||
- fan-in
|
||||
- debounce
|
||||
- throttle (UI)
|
||||
- hydration mismatch
|
||||
- memory leak
|
||||
- GC pause
|
||||
- heap fragmentation
|
||||
- stack overflow
|
||||
- null pointer
|
||||
- dangling pointer
|
||||
- buffer overflow
|
||||
Curated jargon list lives at `~/.claude/skills/gstack/scripts/jargon-list.json` (80+ terms). On the first jargon term you encounter this session, Read that file once; treat the `terms` array as the canonical list. The list is repo-owned and may grow between releases.
|
||||
|
||||
|
||||
## Completeness Principle — Boil the Lake
|
||||
|
||||
@@ -27,7 +27,32 @@ export interface ResolveImpl {
|
||||
|
||||
const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, { stdio: 'pipe', timeout: 60_000 });
|
||||
|
||||
/**
|
||||
* Default resolver. Uses `dns.lookup` (getaddrinfo, goes through mDNSResponder
|
||||
* on macOS) instead of `dns.resolve6` (libresolv, does NOT consult mDNS on
|
||||
* recent macOS — returns ESERVFAIL for `*.coredevice.local`).
|
||||
*
|
||||
* Prefer the IPv6 record but fall back to whatever getaddrinfo returns.
|
||||
*/
|
||||
const defaultResolve: ResolveImpl = async (hostname) => {
|
||||
const dns = await import('dns');
|
||||
return new Promise((resolve, reject) => {
|
||||
dns.lookup(hostname, { family: 6, all: true }, (err, addrs) => {
|
||||
if (err) { reject(err); return; }
|
||||
const ipv6 = (addrs ?? []).filter((a) => a.family === 6).map((a) => a.address);
|
||||
if (ipv6.length === 0) { reject(new Error(`no IPv6 records for ${hostname}`)); return; }
|
||||
resolve(ipv6);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Last-resort resolver using `dns.resolve6`. Kept for backwards compatibility
|
||||
* and for environments where mDNSResponder is not in the resolver chain. On
|
||||
* macOS 26.x (Darwin 25.x) this typically fails with ESERVFAIL — see comment
|
||||
* on `defaultResolve` above.
|
||||
*/
|
||||
const legacyResolve6: ResolveImpl = async (hostname) => {
|
||||
const dns = await import('dns');
|
||||
return new Promise((resolve, reject) => {
|
||||
dns.resolve6(hostname, (err, addrs) => {
|
||||
@@ -69,6 +94,89 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CoreDevice tunnel's IPv6 address from `devicectl device info
|
||||
* details --json-output`. This is the most reliable path on macOS 26.x: the
|
||||
* tunnel IPv6 lives in `result.connectionProperties.tunnelIPAddress` and is
|
||||
* authoritative (it's what CoreDevice itself uses to route).
|
||||
*
|
||||
* A side effect of running `devicectl device info details` is that it forces
|
||||
* CoreDevice to bring up / refresh the tunnel session, which is why we prefer
|
||||
* this over mDNS even on machines where mDNS works.
|
||||
*
|
||||
* Returns null when the device isn't found, isn't tunneled, or devicectl
|
||||
* fails — callers should fall through to mDNS resolution.
|
||||
*/
|
||||
export function getDeviceTunnelIPv6FromDevicectl(
|
||||
udid: string,
|
||||
spawn: SpawnImpl = defaultSpawn,
|
||||
): string | null {
|
||||
const tmp = join(tmpdir(), `devicectl-details-${process.pid}-${Date.now()}.json`);
|
||||
try {
|
||||
const r = spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]);
|
||||
if (r.status !== 0) return null;
|
||||
const raw = readFileSync(tmp, 'utf-8');
|
||||
const obj = JSON.parse(raw);
|
||||
// `result.connectionProperties.tunnelIPAddress` is the canonical location.
|
||||
// Some Xcode/CoreDevice versions also surface it under `result.tunnel.ipAddress`
|
||||
// — accept either.
|
||||
const conn = obj?.result?.connectionProperties as Record<string, unknown> | undefined;
|
||||
const tunnel = obj?.result?.tunnel as Record<string, unknown> | undefined;
|
||||
const addr = (conn?.tunnelIPAddress ?? tunnel?.ipAddress) as string | undefined;
|
||||
if (typeof addr === 'string' && addr.includes(':')) return addr;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a periodic devicectl `info details` poll that keeps the CoreDevice
|
||||
* tunnel session alive. Xcode 26's CoreDevice only holds the tunnel up while
|
||||
* a devicectl command is in-flight or Xcode itself is debugging. Without
|
||||
* something poking it, the tunnel IPv6 becomes unroutable within seconds —
|
||||
* `curl` to the address times out even though the address looks valid.
|
||||
*
|
||||
* Implementation note: we chose `device info details` (cheap, ~10ms of CPU
|
||||
* per tick, no persistent child process) over `device console` (which would
|
||||
* keep the tunnel up continuously but spams stdout, can wedge on backpressure,
|
||||
* and is harder to kill cleanly). The 5-second interval is comfortably under
|
||||
* the empirically-observed tunnel teardown timeout (~10-15s of idle).
|
||||
*
|
||||
* Returns a `stop()` function that cancels the timer. Safe to call multiple
|
||||
* times.
|
||||
*/
|
||||
export function startTunnelKeepalive(
|
||||
udid: string,
|
||||
opts: { intervalMs?: number; spawn?: SpawnImpl } = {},
|
||||
): { stop: () => void } {
|
||||
const intervalMs = opts.intervalMs ?? 5_000;
|
||||
const spawn = opts.spawn ?? defaultSpawn;
|
||||
let stopped = false;
|
||||
const tick = () => {
|
||||
if (stopped) return;
|
||||
// Fire-and-forget: ignore result, the side-effect of the spawn is what
|
||||
// keeps the tunnel up. We deliberately do not use the JSON output here.
|
||||
try {
|
||||
const tmp = join(tmpdir(), `devicectl-keepalive-${process.pid}-${Date.now()}.json`);
|
||||
spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]);
|
||||
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
} catch { /* ignore — next tick will retry */ }
|
||||
};
|
||||
const handle = setInterval(tick, intervalMs);
|
||||
// Don't keep the event loop alive just for this — daemon owns the lifecycle.
|
||||
if (typeof handle.unref === 'function') handle.unref();
|
||||
return {
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(handle);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CoreDevice tunnel's IPv6 address for a device. The hostname is
|
||||
* derived from the device name as printed by `devicectl list devices`. The
|
||||
@@ -95,6 +203,43 @@ export async function getDeviceTunnelIPv6(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a device's tunnel IPv6 using every strategy we know, in order of
|
||||
* decreasing reliability:
|
||||
*
|
||||
* 1. `devicectl device info details --json-output` (most reliable on
|
||||
* macOS 26.x; also has the useful side-effect of bumping the tunnel).
|
||||
* 2. mDNS via `dns.lookup` (getaddrinfo path — does consult mDNSResponder
|
||||
* on macOS, unlike `dns.resolve6`).
|
||||
* 3. mDNS via `dns.resolve6` (legacy path — kept for backwards
|
||||
* compatibility; will ESERVFAIL on recent macOS).
|
||||
*
|
||||
* Returns the first address that any strategy yields, or null.
|
||||
*/
|
||||
export async function resolveTunnelIPv6(opts: {
|
||||
udid: string;
|
||||
deviceName: string;
|
||||
spawn?: SpawnImpl;
|
||||
resolve?: ResolveImpl;
|
||||
legacyResolve?: ResolveImpl;
|
||||
}): Promise<string | null> {
|
||||
const spawn = opts.spawn ?? defaultSpawn;
|
||||
const resolveLookup = opts.resolve ?? defaultResolve;
|
||||
const resolveLegacy = opts.legacyResolve ?? legacyResolve6;
|
||||
|
||||
// 1. devicectl-based
|
||||
const fromDevicectl = getDeviceTunnelIPv6FromDevicectl(opts.udid, spawn);
|
||||
if (fromDevicectl) return fromDevicectl;
|
||||
|
||||
// 2. mDNS via dns.lookup
|
||||
const fromLookup = await getDeviceTunnelIPv6(opts.deviceName, resolveLookup);
|
||||
if (fromLookup) return fromLookup;
|
||||
|
||||
// 3. last-resort: legacy dns.resolve6
|
||||
const fromLegacy = await getDeviceTunnelIPv6(opts.deviceName, resolveLegacy);
|
||||
return fromLegacy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a specific bundle ID has a running process on the device.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ import { mintForCaller } from './auth-mint';
|
||||
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
|
||||
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
|
||||
import { bootstrapTunnel } from './tunnel-bootstrap';
|
||||
import { startTunnelKeepalive } from './devicectl';
|
||||
import type { Capability } from './types';
|
||||
|
||||
interface DaemonOptions {
|
||||
@@ -402,6 +403,12 @@ if (import.meta.main) {
|
||||
// Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with
|
||||
// any connected paired device) is set, bootstrap a real CoreDevice tunnel.
|
||||
// Otherwise return null (proxy will return 503 device_not_connected).
|
||||
//
|
||||
// After a successful bootstrap we spawn a periodic devicectl `info details`
|
||||
// call to keep the CoreDevice tunnel session alive — Xcode 26's CoreDevice
|
||||
// only holds the tunnel up while a devicectl command is in-flight, so
|
||||
// without a poke every few seconds the IPv6 becomes unroutable.
|
||||
let keepalive: { stop: () => void } | null = null;
|
||||
const realTunnelProvider = async () => {
|
||||
const result = await bootstrapTunnel({
|
||||
udid: targetUDID,
|
||||
@@ -411,9 +418,18 @@ if (import.meta.main) {
|
||||
process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`);
|
||||
return null;
|
||||
}
|
||||
if (keepalive) keepalive.stop();
|
||||
keepalive = startTunnelKeepalive(result.tunnel.udid);
|
||||
return result.tunnel;
|
||||
};
|
||||
|
||||
const shutdown = () => {
|
||||
if (keepalive) { keepalive.stop(); keepalive = null; }
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('exit', shutdown);
|
||||
|
||||
startDaemon({
|
||||
loopbackPort: port,
|
||||
tailnetEnabled: tailnet,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { randomBytes } from 'crypto';
|
||||
import type { DeviceTunnel } from './proxy';
|
||||
import {
|
||||
listDevices,
|
||||
getDeviceTunnelIPv6,
|
||||
resolveTunnelIPv6,
|
||||
isAppRunning,
|
||||
launchApp,
|
||||
copyFileFromAppContainer,
|
||||
@@ -97,8 +97,21 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: resolve tunnel IPv6
|
||||
const ipv6 = await getDeviceTunnelIPv6(target.name, resolve);
|
||||
// Step 3: resolve tunnel IPv6. Try devicectl `info details` first (most
|
||||
// reliable on macOS 26.x), fall through to mDNS via dns.lookup, then
|
||||
// dns.resolve6 as a last-ditch fallback. See devicectl.ts:resolveTunnelIPv6
|
||||
// for the rationale.
|
||||
// When tests inject `resolve`, use it for both the mDNS-lookup path AND the
|
||||
// legacy resolve6 path — otherwise the legacy path would make a real DNS
|
||||
// call. In production, only `resolve` is set (to the dns.lookup-based
|
||||
// default) and the legacy path uses the real dns.resolve6.
|
||||
const ipv6 = await resolveTunnelIPv6({
|
||||
udid: target.identifier,
|
||||
deviceName: target.name,
|
||||
spawn,
|
||||
resolve,
|
||||
legacyResolve: resolve,
|
||||
});
|
||||
if (!ipv6) {
|
||||
return { ok: false, error: 'resolve_failed', detail: target.name };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { bootstrapTunnel } from '../src/tunnel-bootstrap';
|
||||
import type { SpawnImpl } from '../src/devicectl';
|
||||
import {
|
||||
getDeviceTunnelIPv6FromDevicectl,
|
||||
resolveTunnelIPv6,
|
||||
startTunnelKeepalive,
|
||||
type SpawnImpl,
|
||||
} from '../src/devicectl';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
interface ScriptedCall {
|
||||
@@ -142,6 +147,12 @@ describe('bootstrapTunnel', () => {
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///private/var/containers/Bundle/Application/.../com.test.app/com.test', processIdentifier: 1234 }] } },
|
||||
stdout: 'com.test',
|
||||
},
|
||||
{
|
||||
// devicectl device info details (devicectl-based IPv6 resolution).
|
||||
// Return no tunnelIPAddress so we fall through to the injected resolver.
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: {} } },
|
||||
},
|
||||
]);
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
@@ -173,6 +184,12 @@ describe('bootstrapTunnel', () => {
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test', processIdentifier: 5678 }] } },
|
||||
stdout: '/com.test.app/',
|
||||
},
|
||||
{
|
||||
// devicectl-based IPv6 resolution succeeds — returns the tunnel
|
||||
// address directly, so the injected resolveImpl is never called.
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device copy from/,
|
||||
destOutput: 'BOOT-TOKEN-XYZ-123\n',
|
||||
@@ -233,6 +250,11 @@ describe('bootstrapTunnel', () => {
|
||||
// jsonOutput body contains the bundle id path, so isAppRunning() returns true.
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
|
||||
},
|
||||
{
|
||||
// devicectl device info details returns no tunnel address.
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: {} } },
|
||||
},
|
||||
]);
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
@@ -258,6 +280,10 @@ describe('bootstrapTunnel', () => {
|
||||
argsMatch: /devicectl device info processes -d B/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info details --device B/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::b' } } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device copy from --device B/,
|
||||
destOutput: 'TOKEN\n',
|
||||
@@ -274,3 +300,132 @@ describe('bootstrapTunnel', () => {
|
||||
if (r.ok) expect(r.tunnel.udid).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeviceTunnelIPv6FromDevicectl', () => {
|
||||
test('extracts tunnelIPAddress from connectionProperties', () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl device info details --device TEST-UDID/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fde4:2827:528e::1' } } },
|
||||
},
|
||||
]);
|
||||
expect(getDeviceTunnelIPv6FromDevicectl('TEST-UDID', spawn)).toBe('fde4:2827:528e::1');
|
||||
});
|
||||
|
||||
test('falls back to result.tunnel.ipAddress when connectionProperties absent', () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { tunnel: { ipAddress: 'fd00::dead:beef' } } },
|
||||
},
|
||||
]);
|
||||
expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBe('fd00::dead:beef');
|
||||
});
|
||||
|
||||
test('returns null when devicectl exits non-zero', () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, exitCode: 1, stderr: 'no such device' },
|
||||
]);
|
||||
expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when tunnelIPAddress missing or non-string', () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, jsonOutput: { result: { connectionProperties: {} } } },
|
||||
]);
|
||||
expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTunnelIPv6 fallback chain', () => {
|
||||
test('prefers devicectl-based resolution', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd11::1' } } },
|
||||
},
|
||||
]);
|
||||
let resolveCalled = false;
|
||||
const addr = await resolveTunnelIPv6({
|
||||
udid: 'U',
|
||||
deviceName: 'Test',
|
||||
spawn,
|
||||
resolve: async () => { resolveCalled = true; return ['fd99::99']; },
|
||||
legacyResolve: async () => { resolveCalled = true; return ['fdAA::AA']; },
|
||||
});
|
||||
expect(addr).toBe('fd11::1');
|
||||
expect(resolveCalled).toBe(false);
|
||||
});
|
||||
|
||||
test('falls through to dns.lookup when devicectl yields no address', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, jsonOutput: { result: { connectionProperties: {} } } },
|
||||
]);
|
||||
let legacyCalled = false;
|
||||
const addr = await resolveTunnelIPv6({
|
||||
udid: 'U',
|
||||
deviceName: 'Test',
|
||||
spawn,
|
||||
resolve: async () => ['fd22::2'],
|
||||
legacyResolve: async () => { legacyCalled = true; return ['fdAA::AA']; },
|
||||
});
|
||||
expect(addr).toBe('fd22::2');
|
||||
expect(legacyCalled).toBe(false);
|
||||
});
|
||||
|
||||
test('falls through to legacy resolve6 when both devicectl and dns.lookup fail', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, exitCode: 1 },
|
||||
]);
|
||||
const addr = await resolveTunnelIPv6({
|
||||
udid: 'U',
|
||||
deviceName: 'Test',
|
||||
spawn,
|
||||
resolve: async () => { throw new Error('ESERVFAIL'); },
|
||||
legacyResolve: async () => ['fd33::3'],
|
||||
});
|
||||
expect(addr).toBe('fd33::3');
|
||||
});
|
||||
|
||||
test('returns null when all three strategies fail', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, exitCode: 1 },
|
||||
]);
|
||||
const addr = await resolveTunnelIPv6({
|
||||
udid: 'U',
|
||||
deviceName: 'Test',
|
||||
spawn,
|
||||
resolve: async () => { throw new Error('ESERVFAIL'); },
|
||||
legacyResolve: async () => { throw new Error('ESERVFAIL'); },
|
||||
});
|
||||
expect(addr).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startTunnelKeepalive', () => {
|
||||
test('invokes devicectl on each interval tick', async () => {
|
||||
const calls: string[] = [];
|
||||
const spawn: SpawnImpl = ((cmd: string, args: string[]) => {
|
||||
calls.push(`${cmd} ${args.slice(0, 4).join(' ')}`);
|
||||
return makeReturn(0, '{}', '');
|
||||
}) as SpawnImpl;
|
||||
const ka = startTunnelKeepalive('UDID-X', { intervalMs: 20, spawn });
|
||||
await new Promise((res) => setTimeout(res, 75));
|
||||
ka.stop();
|
||||
const before = calls.length;
|
||||
// After stop, no more calls.
|
||||
await new Promise((res) => setTimeout(res, 50));
|
||||
expect(calls.length).toBe(before);
|
||||
expect(before).toBeGreaterThanOrEqual(2);
|
||||
expect(calls[0]).toContain('devicectl');
|
||||
expect(calls[0]).toContain('device info details');
|
||||
});
|
||||
|
||||
test('stop() is idempotent', () => {
|
||||
const spawn: SpawnImpl = (() => makeReturn(0, '', '')) as SpawnImpl;
|
||||
const ka = startTunnelKeepalive('U', { intervalMs: 1_000, spawn });
|
||||
ka.stop();
|
||||
ka.stop();
|
||||
// no throw
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user