Files
gstack/ios-qa/daemon/test/tunnel-bootstrap.test.ts
c7554824cb v1.91.38.0 fix(ios-qa): iPads work, a dropped route no longer relaunches the app, loud boot-token write (#3076)
* fix(ios-qa): allow physical iPad devices in tunnel bootstrap (#2743)

(cherry picked from commit d6d43934ef)

* fix(ios-qa): iPhone + iPad on USB names each UDID instead of picking one

With GSTACK_IOS_TARGET_UDID unset, tied default ranks used to pick the first
device devicectl listed, so a QA session could drive the wrong device. Tied
candidates now fail with multiple_devices, list each device and UDID, and
print a ready export line. Device selection moves into selectDevice() so the
route-drop recovery can re-resolve with the same rules.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ios-qa): a dropped CoreDevice route no longer relaunches the app (#1975)

Rotation deletes the app's one-shot boot-token file, so the daemon's old
503/504 path (invalidate + full bootstrap) could only get a new token by
running devicectl launch --terminate-existing, wiping the in-app QA state at
every route blip.

A route failure now goes through recoverTunnel(): re-select the device with
the bootstrap rules, re-resolve the tunnel address for the pinned UDID, check
the unauthenticated /healthz owner, then probe /state/snapshot with the cached
bearer. Outcomes:
- bearer accepted: keep the session (new address if it moved)
- 401: full bootstrap from the fresh boot token (app was relaunched)
- app confirmed absent: bootstrap, which launches it normally once
- different device or an address devicectl cannot tie to the UDID:
  bootstrap; the old bearer is never sent there
- route still down with the app running: surface the error, keep the session

Concurrent failures on one tunnel share a single refresh. Mutations whose
response was lost are still never replayed. The CLI wiring moves into
deviceTunnelSource() so tests drive it against a simulated device.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ios-qa): a failed boot-token write is loud instead of silent (#1837)

StateServer.start() wrote the boot-token file with try?, so a failed write
left nothing on disk and no trace anywhere, and the daemon then relaunched the
app trying to mint a token that could never be written. The write is now a
do/catch that logs the path and error (never the token), logs a NOT READY
bootstrap line, and reports boot_token_error on /healthz. bootstrapTunnel
reads that field and returns boot_token_unavailable naming the device-side
cause instead of running --terminate-existing.

Also drops the stale comments claiming the token was written to os_log.
Template and fixture stay byte-identical.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(ios-qa): type the iPad acceptance test's mDNS stub

ResolveImpl returns Promise<string[]>; resolving null tripped the test-code
typecheck ratchet. A rejected lookup exercises the same resolve_failed path.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ios-qa): iPad support, GSTACK_IOS_TARGET_UDID, route-drop recovery, known limits

- iPhone-only description, triggers, and prerequisites now cover iPads, and
  say how to pick one device with GSTACK_IOS_TARGET_UDID.
- Phase 2 no longer tells you to capture a boot token from os_log (it has
  not been logged for many releases); it describes the 0600 tmp/ file and
  the NOT READY signal (#1837, #1735).
- Source-control guidance for the generated DebugBridge/ package (#1735).
- Failure table: route drops keep the session, multiple_devices, unwritable
  token file, and the one relaunch a daemon restart still costs.
- Known limits: SwiftUI DragGesture and /swipe on iOS 26 (#1975), the
  iOS 26.3.1 /elements data from PR #1755, unverified iPad Stage Manager.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: iOS QA how-to and skill index cover iPads and route-drop recovery

The how-to still said the boot token was readable from os_log and that a
route drop re-bootstraps; both are now accurate, with iPad selection,
GSTACK_IOS_TARGET_UDID, and three new failure rows.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.37.0 fix(ios-qa): iPads work, a route drop no longer relaunches the app, loud boot-token write

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ios-qa): keep issue numbers out of model-read skill text

The archaeology lint rejects issue references in generated SKILL.md text;
the Known limits entry keeps the iOS version and drops the number.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: re-slot to v1.91.38.0 (v1.91.37.0 claimed by #3075)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(ios-qa): device-change recovery test without an IPv4-mapped address

The Windows lane could not reach a fake StateServer through
::ffff:127.0.0.1, so the iPad half of the device-change test timed out. The
fake device now swaps identity on its one ::1 listener, which is also closer
to production: every StateServer answers on the same port.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: loulanyue <260355617@qq.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-08 07:02:02 +00:00

1031 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Bootstrap unit tests. Injects spawn + resolve + fetch stubs so we exercise
// every branch (no_devices, no_paired_device, device_locked, healthz timeout,
// rotate_failed, success) without needing a real iPhone connected.
import { describe, test, expect } from 'bun:test';
import { bootstrapTunnel, recoverTunnel, selectDevice } from '../src/tunnel-bootstrap';
import {
getDeviceTunnelIPv6FromDevicectl,
resolveTunnelIPv6,
startTunnelKeepalive,
type SpawnImpl,
} from '../src/devicectl';
import { writeFileSync } from 'fs';
interface ScriptedCall {
argsMatch: RegExp;
stdout?: string;
stderr?: string;
exitCode?: number;
/** If set, write this content to the JSON output path before returning. */
jsonOutput?: object;
/** If set, write this content to the file matching `--destination`. */
destOutput?: string;
}
/**
* Build a spawnImpl that walks through a scripted sequence of expected calls.
* Each call matches its args against `argsMatch`. Unmatched calls return
* exit-code 1 with an "unexpected call" stderr.
*/
function makeSpawn(scripts: ScriptedCall[]): SpawnImpl {
let idx = 0;
return (cmd: string, args: string[]) => {
const joined = `${cmd} ${args.join(' ')}`;
const script = scripts[idx];
if (!script) {
return makeReturn(1, '', `unexpected call beyond scripted: ${joined}`);
}
if (!script.argsMatch.test(joined)) {
return makeReturn(1, '', `unexpected call shape: ${joined} (expected ${script.argsMatch})`);
}
idx++;
// Honor --json-output: write to that path BEFORE returning.
if (script.jsonOutput) {
const flagIdx = args.indexOf('--json-output');
if (flagIdx !== -1 && args[flagIdx + 1]) {
writeFileSync(args[flagIdx + 1]!, JSON.stringify(script.jsonOutput));
}
}
if (script.destOutput) {
const flagIdx = args.indexOf('--destination');
if (flagIdx !== -1 && args[flagIdx + 1]) {
writeFileSync(args[flagIdx + 1]!, script.destOutput);
}
}
return makeReturn(script.exitCode ?? 0, script.stdout ?? '', script.stderr ?? '');
};
}
function makeReturn(exit: number, stdout: string, stderr: string) {
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('bootstrapTunnel', () => {
test('returns no_devices when devicectl list shows zero', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: { result: { devices: [] } },
},
]);
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('no_devices');
});
test('returns no_paired_device when device is connected but not paired', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: {
devices: [{
identifier: 'TEST-UDID',
connectionProperties: { tunnelState: 'available (pairing)', pairingState: 'unpaired' },
deviceProperties: { name: 'Test iPhone' },
hardwareProperties: { productType: 'iPhone18,2' },
}],
},
},
},
]);
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('no_paired_device');
expect(r.detail).toContain('Trust');
}
});
test('never auto-selects a connected paired Vision Pro ahead of an iPhone', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'VISION',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Vision Pro' },
hardwareProperties: {
productType: 'RealityDevice17,1',
platform: 'visionOS',
deviceType: 'realityDevice',
},
},
{
identifier: 'IPHONE',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
deviceProperties: { name: 'USB iPhone' },
hardwareProperties: {
productType: 'iPhone17,1',
platform: 'iOS',
deviceType: 'iPhone',
},
},
] },
},
},
{
argsMatch: /devicectl device info processes -d IPHONE/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device IPHONE/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::17' } } },
},
{
argsMatch: /devicectl device copy from --device IPHONE/,
destOutput: 'TOKEN\n',
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('IPHONE');
});
test('fails clearly when only non-iOS platforms are connected', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'VISION',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Vision Pro' },
hardwareProperties: {
productType: 'RealityDevice17,1',
platform: 'visionOS',
deviceType: 'realityDevice',
},
},
{
identifier: 'WATCH',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Apple Watch' },
hardwareProperties: {
productType: 'Watch7,5',
platform: 'watchOS',
deviceType: 'watch',
},
},
] },
},
},
]);
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('device_not_found');
expect(r.detail).toContain('no iPhone or iPad');
}
});
test('rejects an explicitly targeted non-iPhone/iPad device', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'VISION',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Vision Pro' },
hardwareProperties: {
productType: 'RealityDevice17,1',
platform: 'visionOS',
deviceType: 'realityDevice',
},
}] },
},
},
]);
const r = await bootstrapTunnel({ bundleId: 'com.test', udid: 'VISION', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('device_not_found');
expect(r.detail).toContain('not an iPhone or iPad');
}
});
test('accepts a physical iPad alongside iPhones', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'IPAD-14-5',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
deviceProperties: { name: 'Test iPad' },
hardwareProperties: {
productType: 'iPad14,5',
platform: 'iOS',
deviceType: 'iPad',
},
}] },
},
},
{
argsMatch: /devicectl device process launch --device IPAD-14-5/,
exitCode: 0,
},
]);
// Fast-fail at resolution stage so we verify device acceptance
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
resolveImpl: () => Promise.reject(new Error('no mDNS record')),
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('resolve_failed');
}
});
test('iPhone and iPad both on USB: lists each UDID and a ready export line instead of guessing', async () => {
const wired = { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' };
const spawn = makeSpawn([{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'IPHONE-UDID',
connectionProperties: wired,
deviceProperties: { name: 'Test iPhone' },
hardwareProperties: { productType: 'iPhone15,2', platform: 'iOS', deviceType: 'iPhone' },
},
{
identifier: 'IPAD-UDID',
connectionProperties: wired,
deviceProperties: { name: 'Test iPad' },
hardwareProperties: { productType: 'iPad14,5', platform: 'iOS', deviceType: 'iPad' },
},
] },
},
}]);
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error).toBe('multiple_devices');
expect(r.detail).toContain('Test iPhone (iPhone): IPHONE-UDID');
expect(r.detail).toContain('Test iPad (iPad): IPAD-UDID');
expect(r.detail).toMatch(/^ export GSTACK_IOS_TARGET_UDID=IPHONE-UDID$/m);
});
test('selectDevice: explicit UDID picks the iPad; a live session breaks a tie for its own device', () => {
const entry = (identifier: string, deviceType: string) => ({
identifier, name: identifier, model: `${deviceType}1,1`, platform: 'iOS', deviceType,
state: 'connected', transport: 'wired', paired: true,
});
const devices = [entry('PHONE', 'iPhone'), entry('PAD', 'iPad')];
expect(selectDevice(devices, 'PAD')).toMatchObject({ ok: true, device: { identifier: 'PAD' } });
expect(selectDevice(devices, undefined, 'PAD')).toMatchObject({ ok: true, device: { identifier: 'PAD' } });
expect(selectDevice(devices)).toMatchObject({ ok: false, error: 'multiple_devices' });
expect(selectDevice([{ ...entry('PAD', 'iPad'), transport: '' }, entry('PHONE', 'iPhone')]))
.toMatchObject({ ok: true, device: { identifier: 'PHONE' } });
});
test('skips an unavailable paired device for a connected or available paired device', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'STALE',
connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
deviceProperties: { name: 'Stale iPhone' },
hardwareProperties: { productType: 'iPhone17,1' },
},
{
identifier: 'WIRED',
connectionProperties: { tunnelState: 'available', pairingState: 'paired', transportType: 'wired' },
deviceProperties: { name: 'Wired iPhone' },
hardwareProperties: { productType: 'iPhone18,2' },
},
] },
},
},
{
argsMatch: /devicectl device info processes -d WIRED/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device WIRED/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::2' } } },
},
{
argsMatch: /devicectl device copy from --device WIRED/,
destOutput: 'TOKEN\n',
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('WIRED');
});
test('accepts a wired iPhone whose tunnel is disconnected until devicectl wakes it', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{
identifier: 'STALE-VISION',
connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
deviceProperties: { name: 'Stale Vision Pro' },
hardwareProperties: { productType: 'RealityDevice14,1' },
},
{
identifier: 'WIRED-DISCONNECTED',
connectionProperties: {
tunnelState: 'disconnected',
pairingState: 'paired',
transportType: 'wired',
},
deviceProperties: { name: 'USB iPhone' },
hardwareProperties: { productType: 'iPhone18,2' },
},
] },
},
},
{
argsMatch: /devicectl device info processes -d WIRED-DISCONNECTED/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device WIRED-DISCONNECTED/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::27' } } },
},
{
argsMatch: /devicectl device copy from --device WIRED-DISCONNECTED/,
destOutput: 'TOKEN\n',
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('WIRED-DISCONNECTED');
});
test('returns device_locked when launchApp errors due to lock', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
jsonOutput: { result: { runningProcesses: [] } },
},
{
argsMatch: /devicectl device process launch/,
stderr: 'Locked ("Unable to launch com.test because the device was not, or could not be, unlocked").',
exitCode: 1,
},
]);
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('device_locked');
});
test('returns state_server_unreachable when healthz never responds', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
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',
spawnImpl: spawn,
resolveImpl: async () => ['fd00::1'],
// fetch always fails.
fetchImpl: (async () => { throw new Error('connection refused'); }) as typeof fetch,
startupTimeoutMs: 200, // short, so test runs fast
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('state_server_unreachable');
});
test('rejects a StateServer owned by a different app bundle', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test' },
hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
jsonOutput: { result: { runningProcesses: [] } },
},
{
argsMatch: /devicectl device process launch/,
},
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::9' } } },
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.expected.app',
spawnImpl: spawn,
fetchImpl: (async () => new Response(
JSON.stringify({ bundle_id: 'com.other.debug-app' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
)) as typeof fetch,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('wrong_app');
expect(r.detail).toContain('com.other.debug-app');
}
});
test('happy path: returns DeviceTunnel with rotated token', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST-UDID',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test Device' },
hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
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',
},
]);
const fetchCalls: Array<{ url: string; method: string }> = [];
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
resolveImpl: async () => ['fd99::beef'],
fetchImpl: (async (url, init) => {
const u = String(url);
const method = (init?.method ?? 'GET').toUpperCase();
fetchCalls.push({ url: u, method });
if (u.endsWith('/healthz')) {
return new Response('{"version":"1.0.0"}', { status: 200 }) as Response;
}
if (u.endsWith('/auth/rotate') && method === 'POST') {
// Verify the boot token is sent (not the rotated one).
const auth = (init?.headers as Record<string, string>)['Authorization'] ?? '';
if (auth !== 'Bearer BOOT-TOKEN-XYZ-123') {
return new Response('wrong bearer', { status: 401 }) as Response;
}
return new Response('{"ok":true}', { status: 200 }) as Response;
}
return new Response('not found', { status: 404 }) as Response;
}) as typeof fetch,
startupTimeoutMs: 1_000,
});
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.tunnel.udid).toBe('TEST-UDID');
expect(r.tunnel.ipv6Addr).toBe('fd99::beef');
expect(r.tunnel.port).toBe(9999);
expect(r.tunnel.bootTokenRotated).toMatch(/^[A-Za-z0-9_-]+$/);
expect(r.tunnel.bootTokenRotated).not.toBe('BOOT-TOKEN-XYZ-123');
expect(r.tunnel.bootTokenRotated.length).toBeGreaterThan(20);
}
// Verify the bootstrap sequence: /healthz first, /auth/rotate second.
expect(fetchCalls[0]?.url).toContain('/healthz');
expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate');
});
test('relaunches once when a prior daemon consumed the running app boot token', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST-UDID',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test Device' },
hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
},
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
},
{
// A prior daemon rotated the one-use token, so StateServer deleted it.
argsMatch: /devicectl device copy from/,
exitCode: 1,
stderr: 'source does not exist',
},
{
argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
},
{
argsMatch: /devicectl device copy from/,
destOutput: 'FRESH-BOOT-TOKEN\n',
},
]);
const fetchCalls: Array<{ url: string; authorization?: string }> = [];
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async (url, init) => {
const u = String(url);
const headers = init?.headers as Record<string, string> | undefined;
fetchCalls.push({ url: u, authorization: headers?.Authorization });
if (u.endsWith('/healthz')) {
return new Response(JSON.stringify({ bundle_id: 'com.test' }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (u.endsWith('/auth/rotate') && headers?.Authorization === 'Bearer FRESH-BOOT-TOKEN') {
return new Response('{"ok":true}', { status: 200 });
}
return new Response('unauthorized', { status: 401 });
}) as typeof fetch,
startupTimeoutMs: 1_000,
});
expect(r.ok).toBe(true);
expect(fetchCalls.filter((call) => call.url.endsWith('/healthz'))).toHaveLength(2);
expect(fetchCalls.at(-1)?.authorization).toBe('Bearer FRESH-BOOT-TOKEN');
});
test('an app that reports a failed boot-token write gets a clear error, not a relaunch (#1837)', async () => {
const calls: string[] = [];
const scripted = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST-UDID',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test Device' },
hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
},
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
},
{ argsMatch: /devicectl device copy from/, exitCode: 1, stderr: 'source does not exist' },
]);
const spawn: SpawnImpl = (cmd, args) => { calls.push(args.join(' ')); return scripted(cmd, args); };
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async () => new Response(
JSON.stringify({ bundle_id: 'com.test', boot_token_error: 'You don’t have permission to save the file.' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
)) as unknown as typeof fetch,
startupTimeoutMs: 1_000,
});
expect(r).toMatchObject({ ok: false, error: 'boot_token_unavailable' });
if (!r.ok) expect(r.detail).toContain('could not write tmp/gstack-ios-qa.token: You don’t have permission');
expect(calls.some((c) => c.includes('--terminate-existing'))).toBe(false);
});
test('rechecks bundle ownership after recovering a consumed boot token', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST-UDID',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test Device' },
hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
},
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
},
{ argsMatch: /devicectl device copy from/, exitCode: 1 },
{
argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
},
{ argsMatch: /devicectl device copy from/, destOutput: 'FRESH-BOOT-TOKEN\n' },
]);
let healthChecks = 0;
let rotateCalls = 0;
const r = await bootstrapTunnel({
bundleId: 'com.test',
spawnImpl: spawn,
fetchImpl: (async (url) => {
const u = String(url);
if (u.endsWith('/healthz')) {
healthChecks++;
return new Response(JSON.stringify({
bundle_id: healthChecks === 1 ? 'com.test' : 'com.other.debug-app',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
rotateCalls++;
return new Response('{"ok":true}', { status: 200 });
}) as typeof fetch,
startupTimeoutMs: 1_000,
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('wrong_app');
expect(healthChecks).toBe(2);
expect(rotateCalls).toBe(0);
});
test('resolve_failed when hostname cant be resolved to an IPv6', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'TEST', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' },
}] },
},
},
{
argsMatch: /devicectl device info processes/,
// 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',
spawnImpl: spawn,
resolveImpl: async () => { throw new Error('ENOTFOUND'); },
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('resolve_failed');
});
test('respects explicit udid when set', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [
{ identifier: 'A', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'A' }, hardwareProperties: { productType: 'iPhone18,2' } },
{ identifier: 'B', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'B' }, hardwareProperties: { productType: 'iPhone18,2' } },
] },
},
},
{
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',
},
]);
const r = await bootstrapTunnel({
udid: 'B',
bundleId: 'com.test',
spawnImpl: spawn,
resolveImpl: async () => ['fd00::b'],
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.tunnel.udid).toBe('B');
});
});
describe('recoverTunnel (route drop, #1975)', () => {
const failed = { udid: 'PHONE', ipv6Addr: 'fd00::1', port: 9999, bootTokenRotated: 'session-bearer-XXXXXXXX' };
const listing = {
argsMatch: /devicectl list devices/,
jsonOutput: {
result: { devices: [{
identifier: 'PHONE',
connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
deviceProperties: { name: 'Test iPhone' },
hardwareProperties: { productType: 'iPhone15,2', platform: 'iOS', deviceType: 'iPhone' },
}] },
},
};
const details = (addr: string | null) => addr
? { argsMatch: /devicectl device info details --device PHONE/, jsonOutput: { result: { connectionProperties: { tunnelIPAddress: addr } } } }
: { argsMatch: /devicectl device info details --device PHONE/, exitCode: 1 };
const processes = (running: boolean) => ({
argsMatch: /devicectl device info processes -d PHONE/,
jsonOutput: { result: { runningProcesses: running ? [{ executable: 'file:///x/com.test.app/App' }] : [] } },
});
function recordingFetch(reply: (url: string) => Response | Error) {
const seen: Array<{ url: string; authorization?: string }> = [];
const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const headers = (init?.headers ?? {}) as Record<string, string>;
seen.push({ url, authorization: headers['Authorization'] });
const r = reply(url);
if (r instanceof Error) throw r;
return r;
}) as unknown as typeof fetch;
return { seen, fetchImpl };
}
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status });
test('keeps the bearer and follows a new address devicectl reports for the pinned UDID', async () => {
const { seen, fetchImpl } = recordingFetch((url) => url.endsWith('/healthz')
? json(200, { bundle_id: 'com.test.app' })
: json(200, { state: {} }));
const r = await recoverTunnel(failed, {
bundleId: 'com.test.app', spawnImpl: makeSpawn([listing, details('fd00::2')]), fetchImpl,
});
expect(r).toEqual({ action: 'reuse', tunnel: { ...failed, ipv6Addr: 'fd00::2' } });
expect(seen.map((s) => s.url)).toEqual(['http://[fd00::2]:9999/healthz', 'http://[fd00::2]:9999/state/snapshot']);
expect(seen[0]!.authorization).toBeUndefined();
expect(seen[1]!.authorization).toBe(`Bearer ${failed.bootTokenRotated}`);
});
test('an mDNS address devicectl cannot tie to the UDID gets no bearer: bootstrap instead', async () => {
const { seen, fetchImpl } = recordingFetch(() => new Error('must not be called'));
const r = await recoverTunnel(failed, {
bundleId: 'com.test.app',
spawnImpl: makeSpawn([listing, details(null)]),
resolveImpl: async () => ['fd99::7'],
fetchImpl,
});
expect(r.action).toBe('bootstrap');
expect(seen).toHaveLength(0);
});
test('a StateServer now owned by another bundle gets no bearer', async () => {
const { seen, fetchImpl } = recordingFetch(() => json(200, { bundle_id: 'com.other.app' }));
const r = await recoverTunnel(failed, {
bundleId: 'com.test.app', spawnImpl: makeSpawn([listing, details('fd00::1')]), fetchImpl,
});
expect(r.action).toBe('bootstrap');
expect(seen.every((s) => s.authorization === undefined)).toBe(true);
});
test('401 from the probe means the app was replaced: bootstrap', async () => {
const { fetchImpl } = recordingFetch((url) => url.endsWith('/healthz') ? json(200, {}) : json(401, { error: 'unauthorized' }));
const r = await recoverTunnel(failed, {
bundleId: 'com.test.app', spawnImpl: makeSpawn([listing, details('fd00::1')]), fetchImpl,
});
expect(r).toMatchObject({ action: 'bootstrap', reason: expect.stringContaining('rejected the session bearer') });
});
test('route still down: running app keeps the session, absent app bootstraps', async () => {
const down = () => new Error('connect EHOSTUNREACH');
const running = await recoverTunnel(failed, {
bundleId: 'com.test.app',
spawnImpl: makeSpawn([listing, details('fd00::1'), processes(true)]),
fetchImpl: recordingFetch(down).fetchImpl,
});
expect(running.action).toBe('unavailable');
const absent = await recoverTunnel(failed, {
bundleId: 'com.test.app',
spawnImpl: makeSpawn([listing, details('fd00::1'), processes(false)]),
fetchImpl: recordingFetch(down).fetchImpl,
});
expect(absent).toMatchObject({ action: 'bootstrap', reason: expect.stringContaining('is not running') });
});
test('pinned UDID unplugged: surface the error and send nothing', async () => {
const { seen, fetchImpl } = recordingFetch(() => new Error('must not be called'));
const r = await recoverTunnel(failed, {
udid: 'PHONE',
bundleId: 'com.test.app',
spawnImpl: makeSpawn([{ argsMatch: /devicectl list devices/, jsonOutput: { result: { devices: [] } } }]),
fetchImpl,
});
expect(r.action).toBe('unavailable');
expect(seen).toHaveLength(0);
});
});
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
});
});