mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +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>
239 lines
8.4 KiB
TypeScript
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;
|
|
}
|