mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 08:54:04 +01:00
* 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>
1031 lines
39 KiB
TypeScript
1031 lines
39 KiB
TypeScript
// 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
|
||
});
|
||
});
|