Files
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

239 lines
8.4 KiB
TypeScript

// Simulated iOS device for daemon recovery tests: a `devicectl` emulator plus
// a StateServer stand-in with the real auth state machine (one-shot boot
// token file, scrubbed on /auth/rotate; rotated bearer held in memory; a
// relaunch mints a new boot token and forgets the bearer and all app state).
// It lets tests drive the production bootstrap + recovery wiring end-to-end
// without Xcode or an iPhone.
import { createServer, type Server } from 'http';
import type { Socket } from 'net';
import { writeFileSync } from 'fs';
import type { SpawnImpl } from '../src/devicectl';
export interface FakeDeviceEntry {
identifier: string;
name: string;
productType: string;
platform: string;
deviceType: string;
tunnelState?: string;
transportType?: string;
paired?: boolean;
}
export interface FakeDevice {
udid: string;
port: number;
/** Tunnel address `devicectl device info details` reports for `udid`. */
address: string;
/** Entries `devicectl list devices` returns; mutate to simulate re-plugging. */
devices: FakeDeviceEntry[];
/** Every devicectl invocation, joined. */
calls: string[];
/** Every StateServer request, the bearer it carried, and the device it reached. */
requests: Array<{ method: string; path: string; authorization?: string; udid: string }>;
running: boolean;
/** In-app QA state; lost whenever the app process is replaced. */
appState: { generation: number; marker: string | null };
spawn: SpawnImpl;
/** Black-hole the next `count` requests (CoreDevice route blip: no reply until the proxy times out). */
dropConnections(count: number): void;
/** Simulate the app crashing or being stopped. */
stopApp(): void;
/** Simulate Xcode re-running the app: new process, new boot-token file. */
relaunchExternally(): void;
/**
* Unplug this device and plug in another one that answers at the same
* tunnel address and port (production StateServers all use one port). The
* new device starts with the app not running.
*/
replaceDevice(udid: string, deviceType: 'iPhone' | 'iPad'): void;
close(): Promise<void>;
}
const BUNDLE_PATH = (bundleId: string) => `/private/var/containers/Bundle/Application/FAKE/${bundleId}.app/`;
function ret(status: number, stdout = '', stderr = ''): ReturnType<SpawnImpl> {
return {
pid: 0,
output: [null, Buffer.from(stdout), Buffer.from(stderr)],
stdout: Buffer.from(stdout),
stderr: Buffer.from(stderr),
status,
signal: null,
} as ReturnType<SpawnImpl>;
}
function targetOf(args: string[]): string | undefined {
const flag = args.findIndex((a) => a === '--device' || a === '-d');
return flag === -1 ? undefined : args[flag + 1];
}
function writeJson(args: string[], payload: unknown): void {
const at = args.indexOf('--json-output');
if (at !== -1 && args[at + 1]) writeFileSync(args[at + 1]!, JSON.stringify(payload));
}
function writeListing(args: string[], entries: FakeDeviceEntry[]): void {
writeJson(args, {
result: {
devices: entries.map((d) => ({
identifier: d.identifier,
connectionProperties: {
tunnelState: d.tunnelState ?? 'connected',
pairingState: d.paired === false ? 'unpaired' : 'paired',
transportType: d.transportType ?? 'wired',
},
deviceProperties: { name: d.name },
hardwareProperties: { productType: d.productType, platform: d.platform, deviceType: d.deviceType },
})),
},
});
}
export async function startFakeDevice(opts: {
bundleId: string;
udid?: string;
deviceType?: 'iPhone' | 'iPad';
}): Promise<FakeDevice> {
const entryFor = (udid: string, deviceType: 'iPhone' | 'iPad'): FakeDeviceEntry => ({
identifier: udid,
name: `Fake ${deviceType}`,
productType: deviceType === 'iPad' ? 'iPad14,5' : 'iPhone15,2',
platform: 'iOS',
deviceType,
});
let bootToken: string | null = null;
let bootTokenFile: string | null = null;
let rotatedToken: string | null = null;
let dropping = 0;
const sockets = new Set<Socket>();
const device: FakeDevice = {
udid: opts.udid ?? 'FAKE-UDID-1',
port: 0,
address: '::1',
devices: [],
calls: [],
requests: [],
running: false,
appState: { generation: 0, marker: null },
spawn: undefined as unknown as SpawnImpl,
dropConnections(count) { dropping = count; },
stopApp() {
device.running = false;
rotatedToken = null;
bootToken = null;
},
relaunchExternally() { launch(); },
replaceDevice(udid, deviceType) {
device.stopApp();
device.udid = udid;
device.devices = [entryFor(udid, deviceType)];
device.appState = { generation: 0, marker: null };
},
close: () => new Promise<void>((resolve) => {
for (const socket of sockets) socket.destroy();
server.close(() => resolve());
}),
};
device.devices = [entryFor(device.udid, opts.deviceType ?? 'iPhone')];
const launch = () => {
device.running = true;
device.appState = { generation: device.appState.generation + 1, marker: null };
bootToken = `boot-${device.appState.generation}-${Math.random().toString(36).slice(2)}`;
bootTokenFile = bootToken;
rotatedToken = null;
};
const server: Server = createServer((req, res) => {
if (!device.running) {
req.socket.destroy();
return;
}
if (dropping > 0) {
dropping--;
return;
}
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
const authorization = req.headers.authorization;
device.requests.push({ method: req.method ?? '', path: req.url ?? '', authorization, udid: device.udid });
const send = (status: number, body: unknown) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
const bearer = authorization?.startsWith('Bearer ') ? authorization.slice(7) : null;
if (req.method === 'GET' && req.url === '/healthz') {
send(200, { version: '1.0.0', bundle_id: opts.bundleId });
return;
}
if (req.method === 'POST' && req.url === '/auth/rotate') {
if (!bootToken || bearer !== bootToken) { send(401, { error: 'boot_token_invalid' }); return; }
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { new_token: string };
rotatedToken = parsed.new_token;
bootToken = null;
bootTokenFile = null;
send(200, { ok: true });
return;
}
if (!rotatedToken || bearer !== rotatedToken) { send(401, { error: 'unauthorized' }); return; }
if (req.url === '/state/snapshot') { send(200, { state: device.appState }); return; }
if (req.url === '/screenshot') { send(200, { png_base64: 'abc=' }); return; }
if (req.method === 'POST' && req.url === '/tap') {
device.appState.marker = 'tapped';
send(200, { ok: true });
return;
}
send(404, { error: 'not_found' });
});
});
server.on('connection', (socket: Socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '::1', () => resolve());
});
device.port = (server.address() as { port: number }).port;
device.spawn = (cmd, args) => {
const joined = `${cmd} ${args.join(' ')}`;
device.calls.push(joined);
if (/devicectl list devices/.test(joined)) {
writeListing(args, device.devices);
return ret(0);
}
const target = targetOf(args);
if (target !== device.udid) return ret(1, '', `device ${target} not found`);
if (/devicectl device info details/.test(joined)) {
writeJson(args, { result: { connectionProperties: { tunnelIPAddress: device.address } } });
return ret(0);
}
if (/devicectl device info processes/.test(joined)) {
writeJson(args, {
result: { runningProcesses: device.running ? [{ executable: `file://${BUNDLE_PATH(opts.bundleId)}App` }] : [] },
});
return ret(0);
}
if (/devicectl device process launch/.test(joined)) {
if (device.running && !args.includes('--terminate-existing')) return ret(0);
launch();
return ret(0);
}
if (/devicectl device copy from/.test(joined)) {
if (!bootTokenFile) return ret(1, '', 'file not found');
writeFileSync(args[args.indexOf('--destination') + 1]!, bootTokenFile);
return ret(0);
}
return ret(1, '', `unexpected devicectl call: ${joined}`);
};
return device;
}