diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 89e14ae97..d83f17dfc 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -485,6 +485,12 @@ export interface NativeDevtoolsApi { * relaunch. */ activateNetworkInspection(bundleId: string): void; + /** + * True once per gap: capture for this app was turned on while it was already + * running, so the requests before that (its launch among them) were missed. + * Reading it clears it, so only the answer that reports the log says so. + */ + takeCaptureGap(bundleId: string): boolean; getNetworkLog(bundleId: string): NetworkEvent[]; clearNetworkLog(bundleId: string): void; getAppState(bundleId: string): Promise; @@ -665,6 +671,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint(); + const captureGaps = new Set(); const events = new TypedEventEmitter(); const noteInitFailure = (err: unknown): void => { @@ -811,6 +818,9 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint captureGaps.delete(bundleId), + getNetworkLog: (bundleId) => [...(connections.get(bundleId)?.networkLog ?? [])], clearNetworkLog: (bundleId) => { @@ -1156,6 +1170,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint; -type Result = NativeDevtoolsPrecheckBlock | { status: "ok"; count: number; events: NetworkEvent[] }; +type Result = + | NativeDevtoolsPrecheckBlock + | { status: "ok"; count: number; events: NetworkEvent[]; note?: string }; export const nativeNetworkLogsTool: ToolDefinition = { id: "native-network-logs", @@ -37,6 +39,7 @@ export const nativeNetworkLogsTool: ToolDefinition = { Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that never pass through JS XMLHttpRequest or fetch. Use when you need to inspect native-level HTTP traffic that is invisible to the JS-level capture. Returns { status, count, events } where each event contains URL, method, status code, headers, and timing. +Capture for an app starts on the first call for it, and stays on across relaunches (except on a device whose devtools agent argent attached to, where a relaunch ends it). So the first ok answer after capture started for an app that was already running carries a note that requests made before that, including the app's launch, were not recorded, so count 0 there does not mean the app made no requests. To capture the launch, restart-app and call again, unless the note says the launch cannot be recorded on that device. If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help. Restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If the same status comes back after that restart, stop restarting: follow the message, which names the terminal fallback. If status is connect_pending: the app is injected and still connecting. Do not restart it, wait a few seconds and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised. Follow the message (re-boot the simulator) rather than retrying this tool. A not-running app comes back as one of those statuses rather than a failure. An app that is not connected does too, except on a device whose devtools agent argent attached to rather than armed itself: there it fails with \`NATIVE_DEVTOOLS_NOT_CONNECTED\`, since no restart of ours can complete a handshake we do not own. Failures are separate: an Apple system app is rejected outright (terminal: never retry it), while a missing host dependency or a udid that is not an Apple device is not.`, zodSchema, @@ -50,6 +53,7 @@ A not-running app comes back as one of those statuses rather than a failure. An const api = services.nativeDevtools as NativeDevtoolsApi; const blocked = await precheckNativeDevtools(api, params.udid, params.bundleId); + api.activateNetworkInspection(params.bundleId); if (blocked) return blocked; /** @@ -69,10 +73,21 @@ A not-running app comes back as one of those statuses rather than a failure. An ); } - api.activateNetworkInspection(params.bundleId); - const events = api.getNetworkLog(params.bundleId).slice(-params.limit); if (params.clear) api.clearNetworkLog(params.bundleId); - return { status: "ok", count: events.length, events }; + return { + status: "ok", + count: events.length, + events, + ...(api.takeCaptureGap(params.bundleId) && { + note: + `Network capture for ${params.bundleId} started while the app was already running. ` + + `Requests made before that, including the app's launch, were not recorded. ` + + (api.armsEnv + ? `To capture the launch, restart-app and call again.` + : `On this device the app's lifecycle belongs to its provider and a relaunch ends ` + + `capture, so its launch cannot be recorded here. Do not restart the app for it.`), + }), + }; }, }; diff --git a/packages/tool-server/test/describe-tool.test.ts b/packages/tool-server/test/describe-tool.test.ts index 4947735b4..2bfe9b352 100644 --- a/packages/tool-server/test/describe-tool.test.ts +++ b/packages/tool-server/test/describe-tool.test.ts @@ -61,6 +61,7 @@ function makeNativeDevtoolsApi(options: { appConnectionState: async () => options.state ?? (options.requiresRestart ? "stale_process" : "connected"), activateNetworkInspection: () => {}, + takeCaptureGap: () => false, getNetworkLog: () => [], clearNetworkLog: () => {}, getAppState: async (bundleId) => ({ diff --git a/packages/tool-server/test/external-native-devtools.test.ts b/packages/tool-server/test/external-native-devtools.test.ts index 338df6ea6..48830f76b 100644 --- a/packages/tool-server/test/external-native-devtools.test.ts +++ b/packages/tool-server/test/external-native-devtools.test.ts @@ -39,18 +39,23 @@ let temporaryDirectory: string; type LentAgent = { close: () => Promise; hangUp: () => void; + /** Sends the deferred `Control` frame of a `deferHandshake` agent. */ + handshake: () => void; received: Array<{ type: string; payload: Record }>; socketPath: string; }; -async function startLentAgent(): Promise { +async function startLentAgent(options: { deferHandshake?: boolean } = {}): Promise { const socketPath = path.join(temporaryDirectory, "agent.sock"); const received: LentAgent["received"] = []; const sockets: net.Socket[] = []; + const sendHandshake = (socket: net.Socket) => + socket.write(JSON.stringify({ payload: { bundleId: BUNDLE_ID }, type: "Control" }) + "\n"); + const server = net.createServer((socket) => { sockets.push(socket); - socket.write(JSON.stringify({ payload: { bundleId: BUNDLE_ID }, type: "Control" }) + "\n"); + if (!options.deferHandshake) sendHandshake(socket); readline.createInterface({ input: socket }).on("line", (raw) => { const message = JSON.parse(raw); @@ -76,6 +81,9 @@ async function startLentAgent(): Promise { hangUp: () => { for (const socket of sockets.splice(0)) socket.destroy(); }, + handshake: () => { + for (const socket of sockets) sendHandshake(socket); + }, received, socketPath, }; @@ -243,6 +251,42 @@ describe("a provider that lends its native-devtools agent", () => { await agent.close(); }); + /** + * The lent app was running before we attached, so its launch is missed. The + * note must say so on the first ok answer, even when an earlier call failed + * because the handshake had not landed, and must not send the agent to + * restart an app whose relaunch ends capture here. + */ + it("notes the missed launch on the first ok answer without advising a restart", async () => { + const agent = await startLentAgent({ deferHandshake: true }); + publishDescriptor({ socketPath: agent.socketPath }); + + const instance = await instantiate(); + const { nativeNetworkLogsTool } = + await import("../src/tools/native-devtools/native-network-logs"); + const call = () => + nativeNetworkLogsTool.execute( + { nativeDevtools: instance.api }, + { bundleId: BUNDLE_ID, clear: false, limit: 50, udid: DEVICE_ID } + ); + + await expect(call()).rejects.toThrow(/not connected/); + + agent.handshake(); + await waitForConnectedBundle(instance.api); + + const first = (await call()) as { status: string; note?: string }; + expect(first.status).toBe("ok"); + expect(first.note).toMatch(/were not recorded/); + expect(first.note).not.toMatch(/restart-app/); + + const second = (await call()) as { note?: string }; + expect(second.note).toBeUndefined(); + + await instance.dispose(); + await agent.close(); + }); + /** * We are the client on this path, so nothing re-dials on its own. Without a * terminated signal the registry would keep serving this instance and its diff --git a/packages/tool-server/test/ios-launch-args.test.ts b/packages/tool-server/test/ios-launch-args.test.ts index 7cd2381b4..93b347ace 100644 --- a/packages/tool-server/test/ios-launch-args.test.ts +++ b/packages/tool-server/test/ios-launch-args.test.ts @@ -45,6 +45,7 @@ function makeNativeApi(): NativeDevtoolsApi { listConnectedBundleIds: () => [], appConnectionState: async () => "connected", activateNetworkInspection: () => {}, + takeCaptureGap: () => false, getNetworkLog: () => [], clearNetworkLog: () => {}, getAppState: async () => { diff --git a/packages/tool-server/test/launch-restart-system-app.test.ts b/packages/tool-server/test/launch-restart-system-app.test.ts index ffe9b9569..46a2dd696 100644 --- a/packages/tool-server/test/launch-restart-system-app.test.ts +++ b/packages/tool-server/test/launch-restart-system-app.test.ts @@ -58,6 +58,7 @@ function makeNativeApi(): NativeDevtoolsApi { listConnectedBundleIds: () => [], appConnectionState: async () => "connected", activateNetworkInspection: () => {}, + takeCaptureGap: () => false, getNetworkLog: () => [], clearNetworkLog: () => {}, getAppState: async () => { diff --git a/packages/tool-server/test/native-devtools-status.test.ts b/packages/tool-server/test/native-devtools-status.test.ts index 51a1506f8..a516992db 100644 --- a/packages/tool-server/test/native-devtools-status.test.ts +++ b/packages/tool-server/test/native-devtools-status.test.ts @@ -76,6 +76,7 @@ function makeNativeApi(options: { return options.state ?? (options.appRunning ? "stale_process" : "not_running"); }, activateNetworkInspection: () => {}, + takeCaptureGap: () => false, getNetworkLog: () => [], clearNetworkLog: () => {}, getAppState: async () => { diff --git a/packages/tool-server/test/native-network-logs-activation.test.ts b/packages/tool-server/test/native-network-logs-activation.test.ts new file mode 100644 index 000000000..64248eeea --- /dev/null +++ b/packages/tool-server/test/native-network-logs-activation.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../src/blueprints/native-devtools", async () => { + const actual = await vi.importActual( + "../src/blueprints/native-devtools" + ); + return { + ...actual, + precheckNativeDevtools: vi.fn(async () => ({ + status: "restart_required", + message: "restart the app", + })), + }; +}); + +const UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"; + +describe("native-network-logs before the app is instrumented", () => { + beforeEach(async () => { + const { __resetDepCacheForTests, __primeDepCacheForTests } = + await import("../src/utils/check-deps"); + __resetDepCacheForTests(); + __primeDepCacheForTests(["xcrun"]); + }); + + it("registers network capture before answering restart_required", async () => { + const activateNetworkInspection = vi.fn(); + const api = { activateNetworkInspection } as any; + const { nativeNetworkLogsTool } = + await import("../src/tools/native-devtools/native-network-logs"); + + const result = await nativeNetworkLogsTool.execute( + { nativeDevtools: api }, + { udid: UDID, bundleId: "com.example.app", limit: 50, clear: false } + ); + + expect(result).toMatchObject({ status: "restart_required" }); + // So the relaunched app starts capturing at its handshake. + expect(activateNetworkInspection).toHaveBeenCalledWith("com.example.app"); + }); + + it("keeps the missed-requests note for the first ok answer", async () => { + const { precheckNativeDevtools } = await import("../src/blueprints/native-devtools"); + // Blocked once (init_failed after activation reached a live connection), then through. + vi.mocked(precheckNativeDevtools) + .mockResolvedValueOnce({ status: "init_failed", message: "re-boot" } as any) + .mockResolvedValueOnce(null); + let gap = false; + const api = { + activateNetworkInspection: vi.fn(() => { + gap = true; + }), + takeCaptureGap: vi.fn(() => { + const had = gap; + gap = false; + return had; + }), + isConnected: () => true, + getNetworkLog: () => [], + armsEnv: true, + } as any; + const { nativeNetworkLogsTool } = + await import("../src/tools/native-devtools/native-network-logs"); + const params = { udid: UDID, bundleId: "com.example.app", limit: 50, clear: false }; + + expect(await nativeNetworkLogsTool.execute({ nativeDevtools: api }, params)).toMatchObject({ + status: "init_failed", + }); + expect(api.takeCaptureGap).not.toHaveBeenCalled(); + + const ok = (await nativeNetworkLogsTool.execute({ nativeDevtools: api }, params)) as { + status: string; + note?: string; + }; + expect(ok.status).toBe("ok"); + expect(ok.note).toMatch(/were not recorded.*restart-app/); + }); +}); diff --git a/packages/tool-server/test/native-network-logs-capture.test.ts b/packages/tool-server/test/native-network-logs-capture.test.ts new file mode 100644 index 000000000..8c96b103f --- /dev/null +++ b/packages/tool-server/test/native-network-logs-capture.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as net from "node:net"; +import { FAILURE_CODES, getFailureSignal, type DeviceInfo } from "@argent/registry"; + +// Capture is armed by the tool but delivered by the blueprint: straight away to +// a connected app, or at the handshake of one that connects later (the relaunch +// `restart_required` asks for). These run the real blueprint against a fake app +// on its socket, so dropping either half brings the lost-launch bug back here. + +vi.mock("@argent/native-devtools-ios", () => ({ + bootstrapDylibPath: () => "/fake/dylibs/libArgentInjectionBootstrap.dylib", + bootstrapDylibPathTcp: () => "/fake/dylibs/tcp/libArgentInjectionBootstrap.dylib", + bootstrapDylibPathTvos: () => "/fake/dylibs/tvos/libArgentInjectionBootstrap.dylib", + tcpInjectionDylibs: () => [], + axServiceBinaryPath: () => "/fake/ax-service", + axServiceBinaryPathTcp: () => "/fake/ax-service-tcp", +})); + +type ExecCb = (err: Error | null, out: { stdout: string; stderr: string }) => void; + +// Every simctl call succeeds with empty output, so `launchctl list` shows no +// running app and the precheck answers restart_required. +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: (_cmd: string, args: readonly string[], opts: unknown, cb?: ExecCb) => { + const callback = (typeof opts === "function" ? opts : cb!) as ExecCb; + const argv = args.join(" "); + if (argv.includes("simctl list")) { + callback(null, { stdout: JSON.stringify({ devices: {} }), stderr: "" }); + return; + } + callback(null, { stdout: "", stderr: "" }); + }, + }; +}); + +import { nativeDevtoolsBlueprint, type NativeDevtoolsApi } from "../src/blueprints/native-devtools"; +import { nativeNetworkLogsTool } from "../src/tools/native-devtools/native-network-logs"; +import { processScopedUdid } from "./helpers/process-scoped-udid"; + +const UDID = processScopedUdid("-5555-6666-7777-888888888888"); +const BUNDLE = "com.example.app"; +const device: DeviceInfo = { id: UDID, platform: "ios", kind: "simulator" }; +const ACTIVATE = JSON.stringify({ + type: "Control", + payload: { command: "activateNetworkInspection" }, +}); + +const settle = () => new Promise((resolve) => setTimeout(resolve, 80)); + +/** Dial the service as the injected dylib does, recording every line it is sent. */ +async function connectApp(socketPath: string, bundleId: string) { + const received: string[] = []; + let buffer = ""; + const sock = net.createConnection(socketPath, () => { + sock.write(JSON.stringify({ type: "Control", payload: { bundleId } }) + "\n"); + }); + sock.on("data", (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop()!; + received.push(...lines); + }); + await settle(); + return { received, close: () => sock.destroy() }; +} + +async function withService(run: (api: NativeDevtoolsApi) => Promise) { + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + try { + await run(instance.api as NativeDevtoolsApi); + } finally { + await instance.dispose(); + } +} + +const callTool = (api: NativeDevtoolsApi, bundleId = BUNDLE) => + nativeNetworkLogsTool.execute( + { nativeDevtools: api }, + { udid: UDID, bundleId, limit: 50, clear: false } + ); + +describe("native-network-logs arms capture through the blueprint", () => { + beforeEach(async () => { + const { __resetDepCacheForTests, __primeDepCacheForTests } = + await import("../src/utils/check-deps"); + __resetDepCacheForTests(); + __primeDepCacheForTests(["xcrun"]); + }); + + it("activates a connected app and says the first answer missed earlier requests", async () => { + await withService(async (api) => { + const app = await connectApp(api.socketPath, BUNDLE); + + const first = await callTool(api); + + await vi.waitFor(() => expect(app.received).toContain(ACTIVATE)); + expect(first).toMatchObject({ status: "ok", count: 0 }); + expect((first as { note?: string }).note).toMatch(/were not recorded.*restart-app/); + + // Capture was already on for the second call, so its count is the truth. + const second = await callTool(api); + expect(second).toMatchObject({ status: "ok" }); + expect((second as { note?: string }).note).toBeUndefined(); + app.close(); + }); + }); + + it("activates an app at its handshake after answering restart_required", async () => { + await withService(async (api) => { + const result = await callTool(api); + expect(result).toMatchObject({ status: "restart_required" }); + + // The relaunch: capture must be on before the app sends its first request. + const app = await connectApp(api.socketPath, BUNDLE); + await vi.waitFor(() => expect(app.received).toContain(ACTIVATE)); + + // That launch was captured, so the next answer carries no note. + const next = await callTool(api); + expect(next).toMatchObject({ status: "ok" }); + expect((next as { note?: string }).note).toBeUndefined(); + app.close(); + }); + }); + + it("re-activates an already armed app when it reconnects after restart-app", async () => { + await withService(async (api) => { + const before = await connectApp(api.socketPath, BUNDLE); + await callTool(api); + before.close(); + await settle(); + + const after = await connectApp(api.socketPath, BUNDLE); + await vi.waitFor(() => expect(after.received).toContain(ACTIVATE)); + after.close(); + }); + }); + + it("never activates an Apple system app it refuses", async () => { + const SYSTEM_APP = "com.apple.Preferences"; + await withService(async (api) => { + const connected = await connectApp(api.socketPath, SYSTEM_APP); + + const err = await callTool(api, SYSTEM_APP).catch((e: unknown) => e); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_NOT_INJECTABLE); + await settle(); + expect(connected.received).not.toContain(ACTIVATE); + connected.close(); + await settle(); + + // Nor at a later launch. + const relaunched = await connectApp(api.socketPath, SYSTEM_APP); + expect(relaunched.received).not.toContain(ACTIVATE); + relaunched.close(); + }); + }); +}); diff --git a/packages/tool-server/test/native-target-app.test.ts b/packages/tool-server/test/native-target-app.test.ts index 36b4c148e..b3d70e068 100644 --- a/packages/tool-server/test/native-target-app.test.ts +++ b/packages/tool-server/test/native-target-app.test.ts @@ -34,6 +34,7 @@ function makeApi(apps: NativeAppState[]): NativeDevtoolsApi { listConnectedBundleIds: () => [...byBundleId.keys()], appConnectionState: async () => "connected", activateNetworkInspection: () => {}, + takeCaptureGap: () => false, getNetworkLog: () => [], clearNetworkLog: () => {}, getAppState: async (bundleId) => { diff --git a/packages/tool-server/test/simulator-watcher-external-claim.test.ts b/packages/tool-server/test/simulator-watcher-external-claim.test.ts index 30e41f919..2868c27ff 100644 --- a/packages/tool-server/test/simulator-watcher-external-claim.test.ts +++ b/packages/tool-server/test/simulator-watcher-external-claim.test.ts @@ -112,6 +112,7 @@ function makeApi(): NativeDevtoolsApi { listConnectedBundleIds: () => [], appConnectionState: async () => "stale_process", activateNetworkInspection: () => {}, + takeCaptureGap: () => false, getNetworkLog: () => [], clearNetworkLog: () => {}, getAppState: async () => { diff --git a/packages/tool-server/test/simulator-watcher-init-failure.test.ts b/packages/tool-server/test/simulator-watcher-init-failure.test.ts index 40dcdecc4..11cc3f1d2 100644 --- a/packages/tool-server/test/simulator-watcher-init-failure.test.ts +++ b/packages/tool-server/test/simulator-watcher-init-failure.test.ts @@ -74,6 +74,7 @@ function makeFailingApi(): { api: NativeDevtoolsApi; ensureCalls: () => number } listConnectedBundleIds: () => [], appConnectionState: async () => "stale_process", activateNetworkInspection: () => {}, + takeCaptureGap: () => false, getNetworkLog: () => [], clearNetworkLog: () => {}, getAppState: async () => { @@ -178,6 +179,7 @@ describe("simulator-watcher with api-owned init failure state", () => { listConnectedBundleIds: () => [], appConnectionState: async () => "stale_process", activateNetworkInspection: () => {}, + takeCaptureGap: () => false, getNetworkLog: () => [], clearNetworkLog: () => {}, getAppState: async () => {