This commit is contained in:
Autonomy AI, LLC 2026-06-03 07:36:45 +02:00 committed by GitHub
commit 271ae17b38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 564 additions and 10 deletions

View File

@ -13,6 +13,12 @@
# the first paired connected device is used)
# GSTACK_IOS_TARGET_BUNDLE_ID — bundle ID of the iOS app hosting StateServer
# (default com.gstack.iosqa.fixture)
# GSTACK_IOS_LAUNCH_ENV — JSON dict of env vars set when the daemon
# cold-launches the app, forwarded to
# `devicectl ... --environment-variables`.
# Required for apps that gate their debug
# bridge behind a flag, e.g. BuckHound:
# '{"BH_ENABLE_IOS_QA_BRIDGE":"1"}'
#
# Readiness protocol: prints `READY: port=<n> pid=<pid>` to stdout once both
# listeners are bound. Spawners read stdin with a ~5s timeout to confirm.

View File

@ -270,8 +270,17 @@ export function launchApp(
udid: string,
bundleId: string,
spawn: SpawnImpl = defaultSpawn,
env?: Record<string, string>,
): { ok: boolean; error?: string } {
const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, bundleId]);
const args = ['devicectl', 'device', 'process', 'launch', '--device', udid];
// Forward launch-time env vars (e.g. an app's debug-bridge enable flag) so a
// cold start actually boots the in-app StateServer. Bundle id stays the
// trailing positional arg, after every flag.
if (env && Object.keys(env).length > 0) {
args.push('--environment-variables', JSON.stringify(env));
}
args.push(bundleId);
const r = spawn('xcrun', args);
if (r.status === 0) return { ok: true };
const err = (r.stderr?.toString() ?? '') + (r.stdout?.toString() ?? '');
if (err.includes('was not, or could not be, unlocked')) {

View File

@ -20,7 +20,7 @@ import { SessionTokenStore } from './session-tokens';
import { mintForCaller } from './auth-mint';
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
import { bootstrapTunnel } from './tunnel-bootstrap';
import { acquireTunnel } from './session-cache';
import { startTunnelKeepalive } from './devicectl';
import type { Capability } from './types';
@ -399,6 +399,18 @@ if (import.meta.main) {
const tailnet = process.argv.includes('--tailnet');
const targetUDID = process.env.GSTACK_IOS_TARGET_UDID;
const bundleId = process.env.GSTACK_IOS_TARGET_BUNDLE_ID ?? 'com.gstack.iosqa.fixture';
// Env vars to set when the daemon cold-launches the app. Apps that gate their
// debug bridge behind a flag (BuckHound: BH_ENABLE_IOS_QA_BRIDGE=1) MUST set
// this, or a cold start launches the app without the bridge and the
// StateServer never binds. Malformed JSON is ignored (warn + no launch env).
let launchEnv: Record<string, string> | undefined;
if (process.env.GSTACK_IOS_LAUNCH_ENV) {
try {
launchEnv = JSON.parse(process.env.GSTACK_IOS_LAUNCH_ENV) as Record<string, string>;
} catch {
process.stderr.write('GSTACK_IOS_LAUNCH_ENV is not valid JSON; ignoring\n');
}
}
// Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with
// any connected paired device) is set, bootstrap a real CoreDevice tunnel.
@ -410,17 +422,22 @@ if (import.meta.main) {
// without a poke every few seconds the IPv6 becomes unroutable.
let keepalive: { stop: () => void } | null = null;
const realTunnelProvider = async () => {
const result = await bootstrapTunnel({
// acquireTunnel reuses a cached rotated bearer (warm-start) when the device
// still honors it, and only falls back to a full single-use boot-token
// rotate on a genuinely fresh app launch. This is what lets a real device
// be driven across tunnel-cache refreshes, daemon restarts, and repeat
// /ios-qa sessions instead of exactly once per app launch.
const tunnel = await acquireTunnel({
udid: targetUDID,
bundleId,
port: 9999, // in-app StateServer port (not the daemon's loopback port)
launchEnv,
logImpl: (m) => process.stderr.write(m + '\n'),
});
if (!result.ok) {
process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`);
return null;
}
if (!tunnel) return null;
if (keepalive) keepalive.stop();
keepalive = startTunnelKeepalive(result.tunnel.udid);
return result.tunnel;
keepalive = startTunnelKeepalive(tunnel.udid);
return tunnel;
};
const shutdown = () => {

View File

@ -0,0 +1,176 @@
// Rotated-token session cache + reuse. This is the Mac-side half of the
// "bootstrap once per app launch" contract that SKILL.md Phase 0 (warm-start)
// describes but the daemon never implemented.
//
// WHY this exists: the in-app StateServer boot token is single-use. The first
// POST /auth/rotate sets bootTokenValid=false AND deletes the on-disk token
// file. The daemon, however, re-runs the full bootstrap (copy boot token +
// rotate) on every tunnel-cache refresh, daemon restart, and new /ios-qa
// session. The second bootstrap then finds the token file gone ->
// `boot_token_unavailable`, so a real device can be driven exactly once per
// app launch. Persisting the rotated bearer the daemon already holds, and
// reusing it after a cheap authenticated probe, makes re-bootstrap unnecessary.
//
// SECURITY: this changes nothing on the device. The StateServer stays
// loopback-only, every endpoint still requires the rotated bearer, and the boot
// token stays single-use. The only new artifact is a Mac-side 0600 cache file
// holding the rotated bearer (the same value SKILL.md Phase 0 already specifies
// the session cache holds), valid only while the app stays launched — a probe
// that returns 401 means the app was relaunched, so we drop the stale token and
// re-bootstrap from a freshly written boot token.
import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'fs';
import { homedir } from 'os';
import { dirname, join } from 'path';
import { bootstrapTunnel, type BootstrapOptions } from './tunnel-bootstrap';
import { getDeviceTunnelIPv6FromDevicectl, type SpawnImpl, type ResolveImpl } from './devicectl';
import type { DeviceTunnel } from './proxy';
export interface SessionCache {
udid: string;
bundleId: string;
port: number;
rotatedToken: string;
ipv6: string;
createdAt: number;
}
export function defaultSessionCachePath(): string {
return process.env.GSTACK_IOS_SESSION_CACHE
?? join(homedir(), '.gstack', 'ios-qa-session.json');
}
/** Read the session cache. Returns null on missing/unreadable/corrupt file. */
export function readSessionCache(path: string = defaultSessionCachePath()): SessionCache | null {
try {
const obj = JSON.parse(readFileSync(path, 'utf-8')) as Partial<SessionCache>;
if (
obj && typeof obj.udid === 'string' && typeof obj.bundleId === 'string'
&& typeof obj.port === 'number' && typeof obj.rotatedToken === 'string'
&& typeof obj.ipv6 === 'string' && typeof obj.createdAt === 'number'
) {
return obj as SessionCache;
}
return null;
} catch {
return null;
}
}
/** Persist the session cache with owner-only (0600) perms. */
export function writeSessionCache(cache: SessionCache, path: string = defaultSessionCachePath()): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(cache), { mode: 0o600 });
// writeFileSync's mode only applies on create; force 0600 on overwrite too.
chmodSync(path, 0o600);
}
/** Remove the session cache (best-effort). */
export function clearSessionCache(path: string = defaultSessionCachePath()): void {
try { rmSync(path, { force: true }); } catch { /* ignore */ }
}
export interface AcquireTunnelOptions {
udid?: string;
bundleId: string;
port: number;
/** Env vars to set if a cold-start bootstrap has to launch the app. */
launchEnv?: Record<string, string>;
// Injection seams (real defaults wire the production impls).
cachePath?: string;
readCacheImpl?: (path?: string) => SessionCache | null;
writeCacheImpl?: (cache: SessionCache, path?: string) => void;
clearCacheImpl?: (path?: string) => void;
resolveIPv6Impl?: (udid: string) => string | null | Promise<string | null>;
probeImpl?: (ipv6: string, port: number, token: string) => Promise<number>;
bootstrapImpl?: (opts: BootstrapOptions) => Promise<import('./tunnel-bootstrap').BootstrapResult>;
/** Diagnostic sink (defaults to no-op; the CLI wires it to stderr). */
logImpl?: (msg: string) => void;
// Pass-throughs to the underlying bootstrap.
spawnImpl?: SpawnImpl;
resolveImpl?: ResolveImpl;
fetchImpl?: typeof fetch;
}
/**
* Acquire a usable DeviceTunnel, reusing a cached rotated bearer when the
* device still honors it and only falling back to a full boot-token bootstrap
* when there is no usable cache or the cached bearer is rejected (app
* relaunched). Returns null on a transient device-unreachable condition (cache
* preserved) or a failed bootstrap.
*/
export async function acquireTunnel(opts: AcquireTunnelOptions): Promise<DeviceTunnel | null> {
const cachePath = opts.cachePath;
const readCache = opts.readCacheImpl ?? readSessionCache;
const writeCache = opts.writeCacheImpl ?? writeSessionCache;
const clearCache = opts.clearCacheImpl ?? clearSessionCache;
const resolveIPv6 = opts.resolveIPv6Impl ?? ((udid: string) => getDeviceTunnelIPv6FromDevicectl(udid, opts.spawnImpl));
const probe = opts.probeImpl ?? defaultProbe(opts.fetchImpl);
const bootstrap = opts.bootstrapImpl ?? bootstrapTunnel;
const log = opts.logImpl ?? (() => {});
const cache = readCache(cachePath);
const cacheUsable = !!cache
&& cache.bundleId === opts.bundleId
&& cache.port === opts.port
&& (!opts.udid || cache.udid === opts.udid);
if (cache && cacheUsable) {
const ipv6 = await resolveIPv6(cache.udid);
if (!ipv6) { log('device unresolvable; keeping cached session token, will retry'); return null; }
const status = await probe(ipv6, cache.port, cache.rotatedToken);
if (status === 200) {
if (ipv6 !== cache.ipv6) writeCache({ ...cache, ipv6 }, cachePath);
log(`reusing cached session token for ${cache.udid} (no app relaunch needed)`);
return { udid: cache.udid, ipv6Addr: ipv6, port: cache.port, bootTokenRotated: cache.rotatedToken };
}
if (status === 401 || status === 403) {
log(`cached session token rejected (HTTP ${status}); app was relaunched — re-bootstrapping`);
clearCache(cachePath); // app relaunched -> stale rotated token; re-bootstrap below
} else {
log(`device unreachable during token probe (HTTP ${status}); keeping cached token, will retry`);
return null; // 0 (connection error) / 5xx — transient; do not discard a good token
}
}
const result = await bootstrap({
udid: opts.udid,
bundleId: opts.bundleId,
port: opts.port,
launchEnv: opts.launchEnv,
spawnImpl: opts.spawnImpl,
resolveImpl: opts.resolveImpl,
fetchImpl: opts.fetchImpl,
});
if (!result.ok) {
log(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}`);
return null;
}
log(`bootstrapped fresh session token for ${result.tunnel.udid}`);
writeCache({
udid: result.tunnel.udid,
bundleId: opts.bundleId,
port: opts.port,
rotatedToken: result.tunnel.bootTokenRotated,
ipv6: result.tunnel.ipv6Addr,
createdAt: Date.now(),
}, cachePath);
return result.tunnel;
}
function defaultProbe(fetchFn: typeof fetch = fetch) {
return async (ipv6: string, port: number, token: string): Promise<number> => {
const isIPv6 = (ipv6.match(/:/g)?.length ?? 0) >= 2;
const host = isIPv6 ? `[${ipv6}]` : ipv6;
try {
const r = await fetchFn(`http://${host}:${port}/state/snapshot`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(4_000),
});
return r.status;
} catch {
return 0; // connection refused / timeout / tunnel down
}
};
}

View File

@ -34,6 +34,11 @@ export interface BootstrapOptions {
port?: number;
/** Token-path inside the app sandbox (relative to data container). */
bootTokenPath?: string;
/** Env vars to set when the daemon launches the app (cold start). Forwarded
* to `devicectl ... --environment-variables`. Apps that gate their debug
* bridge behind a flag (e.g. BuckHound's BH_ENABLE_IOS_QA_BRIDGE) need this
* or a cold launch never boots the StateServer. */
launchEnv?: Record<string, string>;
/** Max time to wait for the StateServer to start after launch (ms). */
startupTimeoutMs?: number;
/** Test injection. */
@ -91,7 +96,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
// Step 2: launch app (idempotent — devicectl returns success if already running)
if (!isAppRunning(target.identifier, opts.bundleId, spawn)) {
const launched = launchApp(target.identifier, opts.bundleId, spawn);
const launched = launchApp(target.identifier, opts.bundleId, spawn, opts.launchEnv);
if (!launched.ok) {
return { ok: false, error: launched.error === 'device_locked' ? 'device_locked' : 'launch_failed', detail: launched.error };
}

View File

@ -0,0 +1,95 @@
// Cold-start launch env. BuckHound (and any app that gates its debug bridge
// behind an env var) needs the daemon to pass that var when it launches the
// app itself. Without it, a cold start (app not already running) launches the
// app WITHOUT the bridge -> the StateServer never binds -> state_server_unreachable,
// and `/ios-qa` only works if the operator pre-launches by hand. The daemon
// reads GSTACK_IOS_LAUNCH_ENV (a JSON dict) and forwards it to
// `devicectl device process launch --environment-variables`.
import { describe, test, expect } from 'bun:test';
import { launchApp, type SpawnImpl } from '../src/devicectl';
import { bootstrapTunnel } from '../src/tunnel-bootstrap';
import { writeFileSync } from 'fs';
function makeReturn(exit: number, stdout = '', stderr = '') {
return {
pid: 0,
output: [null, Buffer.from(stdout), Buffer.from(stderr)],
stdout: Buffer.from(stdout),
stderr: Buffer.from(stderr),
status: exit,
signal: null,
} as ReturnType<SpawnImpl>;
}
describe('launchApp environment variables', () => {
test('passes --environment-variables with the JSON dict, bundle id stays last', () => {
let captured: string[] = [];
const spawn: SpawnImpl = ((_cmd: string, args: string[]) => { captured = args; return makeReturn(0); }) as SpawnImpl;
const r = launchApp('UDID-1', 'com.test.app', spawn, { BH_ENABLE_IOS_QA_BRIDGE: '1' });
expect(r.ok).toBe(true);
const i = captured.indexOf('--environment-variables');
expect(i).toBeGreaterThan(-1);
expect(JSON.parse(captured[i + 1]!)).toEqual({ BH_ENABLE_IOS_QA_BRIDGE: '1' });
expect(captured[captured.length - 1]).toBe('com.test.app'); // bundle id is the trailing positional
});
test('omits --environment-variables when env is undefined or empty', () => {
for (const env of [undefined, {}]) {
let captured: string[] = [];
const spawn: SpawnImpl = ((_cmd: string, args: string[]) => { captured = args; return makeReturn(0); }) as SpawnImpl;
launchApp('UDID-1', 'com.test.app', spawn, env);
expect(captured.includes('--environment-variables')).toBe(false);
expect(captured[captured.length - 1]).toBe('com.test.app');
}
});
});
describe('bootstrapTunnel forwards launchEnv on cold start', () => {
test('threads launchEnv into the launch when the app is not already running', async () => {
const calls: string[][] = [];
const spawn: SpawnImpl = ((_cmd: string, args: string[]) => {
calls.push(args);
const joined = args.join(' ');
const writeJson = (obj: object) => {
const fi = args.indexOf('--json-output');
if (fi !== -1 && args[fi + 1]) writeFileSync(args[fi + 1]!, JSON.stringify(obj));
};
if (joined.includes('list devices')) {
writeJson({ result: { devices: [{ identifier: 'UDID-1', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone17,1' } }] } });
return makeReturn(0);
}
if (joined.includes('info processes')) { writeJson({ result: { runningProcesses: [] } }); return makeReturn(0); } // not running -> launch
if (joined.includes('process launch')) { return makeReturn(0); }
if (joined.includes('info details')) { writeJson({ result: { connectionProperties: { tunnelIPAddress: 'fd00::1' } } }); return makeReturn(0); }
if (joined.includes('copy from')) {
const fi = args.indexOf('--destination');
if (fi !== -1 && args[fi + 1]) writeFileSync(args[fi + 1]!, 'BOOT-TOK\n');
return makeReturn(0);
}
return makeReturn(1, '', 'unexpected ' + joined);
}) as SpawnImpl;
const r = await bootstrapTunnel({
udid: 'UDID-1',
bundleId: 'com.test.app',
launchEnv: { BH_ENABLE_IOS_QA_BRIDGE: '1' },
spawnImpl: spawn,
resolveImpl: async () => ['fd00::1'],
fetchImpl: (async (url: unknown) => {
const u = String(url);
if (u.endsWith('/healthz')) return new Response('{"version":"1.0.0"}', { status: 200 });
if (u.endsWith('/auth/rotate')) return new Response('{"ok":true}', { status: 200 });
return new Response('nope', { status: 404 });
}) as typeof fetch,
startupTimeoutMs: 1_000,
});
expect(r.ok).toBe(true);
const launchCall = calls.find((a) => a.join(' ').includes('process launch'));
expect(launchCall).toBeDefined();
const i = launchCall!.indexOf('--environment-variables');
expect(i).toBeGreaterThan(-1);
expect(JSON.parse(launchCall![i + 1]!)).toEqual({ BH_ENABLE_IOS_QA_BRIDGE: '1' });
});
});

View File

@ -0,0 +1,246 @@
// Session-cache unit tests. The daemon persists the rotated bearer it obtains
// from a one-time boot-token rotate so that subsequent bootstraps (30s tunnel
// refresh, daemon restart, a new /ios-qa session) REUSE that bearer instead of
// re-copying the now-deleted single-use boot token. Without this, the second
// bootstrap after the first /auth/rotate fails with boot_token_unavailable
// (the StateServer deletes + invalidates the boot token on rotate), so a real
// device can be driven exactly once per app launch.
//
// All decision-logic tests inject cache + resolve + probe + bootstrap stubs so
// no real device / filesystem is needed. The round-trip tests use a temp file.
import { describe, test, expect, afterEach } from 'bun:test';
import {
readSessionCache,
writeSessionCache,
clearSessionCache,
acquireTunnel,
type SessionCache,
} from '../src/session-cache';
import type { BootstrapResult } from '../src/tunnel-bootstrap';
import { mkdtempSync, rmSync, existsSync, statSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
const tmpFiles: string[] = [];
function tmpCachePath(): string {
const dir = mkdtempSync(join(tmpdir(), 'gstack-sc-'));
const p = join(dir, 'ios-qa-session.json');
tmpFiles.push(dir);
return p;
}
afterEach(() => {
while (tmpFiles.length) {
const d = tmpFiles.pop()!;
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
const SAMPLE: SessionCache = {
udid: 'UDID-1',
bundleId: 'com.test.app',
port: 9999,
rotatedToken: 'ROTATED-ABC-123',
ipv6: 'fd00::1',
createdAt: 1_700_000_000_000,
};
function okBootstrap(token = 'FRESH-ROTATED-999', ipv6 = 'fd00::2', udid = 'UDID-1'): () => Promise<BootstrapResult> {
return async () => ({ ok: true, tunnel: { udid, ipv6Addr: ipv6, port: 9999, bootTokenRotated: token } });
}
describe('session cache file I/O', () => {
test('write then read round-trips the cache', () => {
const p = tmpCachePath();
writeSessionCache(SAMPLE, p);
const got = readSessionCache(p);
expect(got).toEqual(SAMPLE);
});
test('writes the cache file with 0600 perms (owner-only)', () => {
const p = tmpCachePath();
writeSessionCache(SAMPLE, p);
const mode = statSync(p).mode & 0o777;
expect(mode).toBe(0o600);
});
test('read returns null for a missing file', () => {
expect(readSessionCache(join(tmpdir(), 'definitely-missing-xyz.json'))).toBeNull();
});
test('clear removes the file and read then returns null', () => {
const p = tmpCachePath();
writeSessionCache(SAMPLE, p);
expect(existsSync(p)).toBe(true);
clearSessionCache(p);
expect(existsSync(p)).toBe(false);
expect(readSessionCache(p)).toBeNull();
});
});
describe('acquireTunnel — reuse path', () => {
test('reuses the cached rotated token when the probe succeeds (no bootstrap, no boot-token copy)', async () => {
let bootstrapCalled = false;
const tunnel = await acquireTunnel({
bundleId: 'com.test.app',
port: 9999,
readCacheImpl: () => SAMPLE,
writeCacheImpl: () => {},
clearCacheImpl: () => {},
resolveIPv6Impl: () => 'fd00::1',
probeImpl: async () => 200,
bootstrapImpl: async () => { bootstrapCalled = true; return { ok: false, error: 'no_devices' }; },
});
expect(bootstrapCalled).toBe(false);
expect(tunnel).not.toBeNull();
expect(tunnel!.bootTokenRotated).toBe('ROTATED-ABC-123');
expect(tunnel!.udid).toBe('UDID-1');
});
test('reuse picks up a refreshed tunnel IPv6 and rewrites the cache', async () => {
let written: SessionCache | null = null;
const tunnel = await acquireTunnel({
bundleId: 'com.test.app',
port: 9999,
readCacheImpl: () => SAMPLE, // cached ipv6 fd00::1
writeCacheImpl: (c) => { written = c; },
clearCacheImpl: () => {},
resolveIPv6Impl: () => 'fd00::beef', // device now on a different tunnel addr
probeImpl: async () => 200,
bootstrapImpl: okBootstrap(),
});
expect(tunnel!.ipv6Addr).toBe('fd00::beef');
expect(tunnel!.bootTokenRotated).toBe('ROTATED-ABC-123'); // token unchanged
expect(written).not.toBeNull();
expect(written!.ipv6).toBe('fd00::beef');
});
});
describe('acquireTunnel — bootstrap path', () => {
test('with no cache, runs a full bootstrap and persists the rotated token', async () => {
let written: SessionCache | null = null;
let probeCalled = false;
const tunnel = await acquireTunnel({
bundleId: 'com.test.app',
port: 9999,
readCacheImpl: () => null,
writeCacheImpl: (c) => { written = c; },
clearCacheImpl: () => {},
resolveIPv6Impl: () => 'fd00::1',
probeImpl: async () => { probeCalled = true; return 200; },
bootstrapImpl: okBootstrap('FRESH-ROTATED-999', 'fd00::2', 'UDID-1'),
});
expect(probeCalled).toBe(false); // no cached token to probe
expect(tunnel!.bootTokenRotated).toBe('FRESH-ROTATED-999');
expect(written).not.toBeNull();
expect(written!.rotatedToken).toBe('FRESH-ROTATED-999');
expect(written!.ipv6).toBe('fd00::2');
});
test('when the cached token is rejected (401), clears cache then re-bootstraps', async () => {
let cleared = false;
let bootstrapCalled = false;
let written: SessionCache | null = null;
const tunnel = await acquireTunnel({
bundleId: 'com.test.app',
port: 9999,
readCacheImpl: () => SAMPLE,
writeCacheImpl: (c) => { written = c; },
clearCacheImpl: () => { cleared = true; },
resolveIPv6Impl: () => 'fd00::1',
probeImpl: async () => 401, // app was relaunched → old rotated token dead
bootstrapImpl: async () => { bootstrapCalled = true; return (await okBootstrap('NEW-TOKEN')()); },
});
expect(cleared).toBe(true);
expect(bootstrapCalled).toBe(true);
expect(tunnel!.bootTokenRotated).toBe('NEW-TOKEN');
expect(written!.rotatedToken).toBe('NEW-TOKEN');
});
test('ignores a cache whose udid does not match an explicitly requested udid', async () => {
let bootstrapCalled = false;
const tunnel = await acquireTunnel({
udid: 'UDID-OTHER',
bundleId: 'com.test.app',
port: 9999,
readCacheImpl: () => SAMPLE, // cache is for UDID-1
writeCacheImpl: () => {},
clearCacheImpl: () => {},
resolveIPv6Impl: () => 'fd00::1',
probeImpl: async () => 200,
bootstrapImpl: async () => { bootstrapCalled = true; return (await okBootstrap('X', 'fd00::9', 'UDID-OTHER')()); },
});
expect(bootstrapCalled).toBe(true);
expect(tunnel!.udid).toBe('UDID-OTHER');
});
});
describe('acquireTunnel — transient + failure handling', () => {
test('returns null WITHOUT clearing the cache when the probe hits a connection error', async () => {
let cleared = false;
let bootstrapCalled = false;
const tunnel = await acquireTunnel({
bundleId: 'com.test.app',
port: 9999,
readCacheImpl: () => SAMPLE,
writeCacheImpl: () => {},
clearCacheImpl: () => { cleared = true; },
resolveIPv6Impl: () => 'fd00::1',
probeImpl: async () => 0, // connection refused / device momentarily unreachable
bootstrapImpl: async () => { bootstrapCalled = true; return { ok: false, error: 'no_devices' }; },
});
expect(tunnel).toBeNull();
expect(cleared).toBe(false); // do NOT destroy a good token on a transient blip
expect(bootstrapCalled).toBe(false);
});
test('returns null and does not write cache when bootstrap fails', async () => {
let written = false;
const tunnel = await acquireTunnel({
bundleId: 'com.test.app',
port: 9999,
readCacheImpl: () => null,
writeCacheImpl: () => { written = true; },
clearCacheImpl: () => {},
resolveIPv6Impl: () => null,
probeImpl: async () => 200,
bootstrapImpl: async () => ({ ok: false, error: 'boot_token_unavailable', detail: 'gone' }),
});
expect(tunnel).toBeNull();
expect(written).toBe(false);
});
});
describe('acquireTunnel — diagnostics', () => {
test('surfaces the bootstrap failure reason via logImpl', async () => {
const logs: string[] = [];
await acquireTunnel({
bundleId: 'com.test.app',
port: 9999,
readCacheImpl: () => null,
writeCacheImpl: () => {},
clearCacheImpl: () => {},
resolveIPv6Impl: () => null,
probeImpl: async () => 200,
bootstrapImpl: async () => ({ ok: false, error: 'boot_token_unavailable', detail: 'gone' }),
logImpl: (m) => logs.push(m),
});
expect(logs.some((l) => l.includes('boot_token_unavailable'))).toBe(true);
});
test('logs that it reused a cached session token (no app relaunch needed)', async () => {
const logs: string[] = [];
await acquireTunnel({
bundleId: 'com.test.app',
port: 9999,
readCacheImpl: () => SAMPLE,
writeCacheImpl: () => {},
clearCacheImpl: () => {},
resolveIPv6Impl: () => 'fd00::1',
probeImpl: async () => 200,
bootstrapImpl: okBootstrap(),
logImpl: (m) => logs.push(m),
});
expect(logs.some((l) => /reus/i.test(l))).toBe(true);
});
});