Files
software-mansion__argent/packages/tool-server/test/network/network-integration.test.ts
Paweł Fornagiel 73b619bfed Require device_id for debugger/profiler; React profiler session metadata; skill updates (#117)
* Reapply "feat: enhance React profiler session handling with device ID support"

This reverts commit 2bf25cb950.

* Reapply "refactor: remove device-related fields from React profiler session"

This reverts commit badf127411.

* Reapply "refactor: unify device ID handling across debugger and profiler tools, make device_id required, not optional"

This reverts commit c6f1fd7578.

* format: run format

* refactor: enforce device ID requirement in JsRuntimeDebugger and ReactProfilerSession blueprints

* feat: add appName and deviceName to React profiler session and related tools

* fix: update React profiler session output to reflect accurate field names for runtime and project details

* skills: enhance skills for Metro debugger and React profiler tools to clarify device_id usage and connection diagnostics

* format: run format

* test: update NetworkInspector integration tests to include mock-device in service resolutions
2026-04-15 10:32:58 +02:00

566 lines
17 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { WebSocketServer, WebSocket } from "ws";
import * as http from "node:http";
import { Registry } from "@argent/registry";
import { jsRuntimeDebuggerBlueprint } from "../../src/blueprints/js-runtime-debugger";
import { networkInspectorBlueprint } from "../../src/blueprints/network-inspector";
import { networkLogsTool } from "../../src/tools/network/network-logs";
import { networkRequestTool } from "../../src/tools/network/network-request";
/**
* Integration test using a mock Metro HTTP + CDP WebSocket server.
* Verifies the network inspector blueprint depends on JsRuntimeDebugger
* (sharing the same CDP connection) and that network tools work end-to-end.
*/
let mockServer: http.Server;
let wss: WebSocketServer;
let mockPort: number;
let registry: Registry;
/**
* Track how many WebSocket connections the mock server receives.
* The key assertion: only ONE CDP connection should be opened even when
* both JsRuntimeDebugger and NetworkInspector are resolved.
*/
let wsConnectionCount = 0;
/**
* Simulated network log state stored in the "JS runtime" (mock).
* When Runtime.evaluate runs the interceptor or log-read scripts,
* we return responses matching what the real runtime would produce.
*/
let interceptorInstalled = false;
const networkLog: Array<{
id: number;
requestId: string;
state: string;
request: { url: string; method: string; headers: Record<string, string> };
response?: {
url: string;
status: number;
statusText: string;
headers: Record<string, string>;
mimeType: string;
};
resourceType: string;
encodedDataLength?: number;
timestamp: number;
durationMs?: number;
responseBody?: string;
}> = [];
function handleCDPMessage(ws: WebSocket, raw: string) {
const msg = JSON.parse(raw);
const { id, method, params } = msg;
switch (method) {
case "FuseboxClient.setClientMetadata":
case "ReactNativeApplication.enable":
case "Runtime.runIfWaitingForDebugger":
ws.send(JSON.stringify({ id, result: {} }));
break;
case "Runtime.enable":
ws.send(JSON.stringify({ id, result: {} }));
break;
case "Debugger.enable":
ws.send(JSON.stringify({ id, result: { debuggerId: "mock-debugger" } }));
ws.send(
JSON.stringify({
method: "Debugger.scriptParsed",
params: {
scriptId: "1",
url: "http://localhost/index.bundle?platform=ios&dev=true",
startLine: 0,
endLine: 50000,
sourceMapURL: "index.bundle.map",
},
})
);
break;
case "Debugger.setPauseOnExceptions":
case "Debugger.setAsyncCallStackDepth":
ws.send(JSON.stringify({ id, result: {} }));
break;
case "Runtime.addBinding":
ws.send(JSON.stringify({ id, result: {} }));
break;
case "Runtime.evaluate": {
const expr = params.expression as string;
// The interceptor script contains "globalThis.fetch =" (monkey-patching),
// while the log-read scripts never do.
if (expr.includes("globalThis.fetch =")) {
// This is the network interceptor installation script
interceptorInstalled = true;
ws.send(
JSON.stringify({
id,
result: {
result: {
type: "string",
value: JSON.stringify({ installed: true }),
},
},
})
);
} else if (expr.includes("__argent_network_log") || expr.includes("__argent_network_by_id")) {
handleLogReadScript(ws, id, expr);
} else {
// Generic evaluate
ws.send(
JSON.stringify({
id,
result: {
result: { type: "string", value: "eval-result" },
},
})
);
}
break;
}
default:
ws.send(
JSON.stringify({
id,
error: {
code: -32601,
message: `Method not found: ${method}`,
},
})
);
}
}
function handleLogReadScript(ws: WebSocket, id: number, expr: string) {
if (expr.includes("__argent_network_by_id")) {
// Detail read script — extract the requestId
const match = expr.match(/byId\['([^']+)'\]/);
const requestId = match ? match[1] : null;
const entry = networkLog.find((e) => e.requestId === requestId);
if (!interceptorInstalled) {
ws.send(
JSON.stringify({
id,
result: {
result: {
type: "string",
value: JSON.stringify({
error: "Network interceptor not installed",
}),
},
},
})
);
} else if (!entry) {
ws.send(
JSON.stringify({
id,
result: {
result: {
type: "string",
value: JSON.stringify({ error: "Request not found" }),
},
},
})
);
} else {
ws.send(
JSON.stringify({
id,
result: {
result: {
type: "string",
value: JSON.stringify(entry),
},
},
})
);
}
return;
}
// List read script — extract start and limit from the script
const startMatch = expr.match(/var start = (\d+)/);
const limitMatch = expr.match(/var limit = (\d+)/);
const start = startMatch ? parseInt(startMatch[1], 10) : 0;
const limit = limitMatch ? parseInt(limitMatch[1], 10) : 50;
// Filter out Metro server requests (like the real interceptor does)
const filtered = networkLog.filter(
(e) =>
!e.request.url.includes(`localhost:${mockPort}`) &&
!e.request.url.includes(`127.0.0.1:${mockPort}`)
);
const slice = filtered.slice(start, limit > 0 ? start + limit : start);
const entries = slice.map((e) => ({
id: e.id,
requestId: e.requestId,
state: e.state,
request: { url: e.request.url, method: e.request.method },
response: e.response
? {
status: e.response.status,
statusText: e.response.statusText,
mimeType: e.response.mimeType,
}
: undefined,
resourceType: e.resourceType,
encodedDataLength: e.encodedDataLength,
timestamp: e.timestamp,
durationMs: e.durationMs,
}));
ws.send(
JSON.stringify({
id,
result: {
result: {
type: "string",
value: JSON.stringify({
entries,
total: filtered.length,
interceptorInstalled,
}),
},
},
})
);
}
beforeAll(async () => {
// Seed mock network log with test data
networkLog.push(
{
id: 0,
requestId: "rn-net-1",
state: "finished",
request: {
url: "https://api.example.com/users",
method: "GET",
headers: { "content-type": "application/json" },
},
response: {
url: "https://api.example.com/users",
status: 200,
statusText: "OK",
headers: {
"content-type": "application/json",
"authorization": "Bearer secret-token",
},
mimeType: "application/json",
},
resourceType: "Fetch",
encodedDataLength: 1234,
timestamp: Date.now() / 1000,
durationMs: 150,
responseBody: JSON.stringify([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
},
{
id: 1,
requestId: "rn-net-2",
state: "failed",
request: {
url: "https://api.example.com/broken",
method: "POST",
headers: {},
},
resourceType: "Fetch",
timestamp: Date.now() / 1000,
durationMs: 50,
}
);
await new Promise<void>((resolve) => {
mockServer = http.createServer((req, res) => {
if (req.url === "/status") {
res.setHeader("X-React-Native-Project-Root", "/mock/project");
res.end("packager-status:running");
return;
}
if (req.url === "/json/list") {
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify([
{
id: "page-1",
title: "React Native (mock)",
description: "[C++ connection]",
webSocketDebuggerUrl: `ws://localhost:${mockPort}/inspector/debug?device=0&page=1`,
deviceName: "MockDevice",
reactNative: {
capabilities: { prefersFuseboxFrontend: true },
},
},
])
);
return;
}
res.statusCode = 404;
res.end("Not found");
});
wss = new WebSocketServer({ server: mockServer });
wss.on("connection", (ws) => {
wsConnectionCount++;
ws.on("message", (raw) => handleCDPMessage(ws, raw.toString()));
});
mockServer.listen(0, () => {
mockPort = (mockServer.address() as { port: number }).port;
resolve();
});
});
registry = new Registry();
registry.registerBlueprint(jsRuntimeDebuggerBlueprint);
registry.registerBlueprint(networkInspectorBlueprint);
registry.registerTool(networkLogsTool);
registry.registerTool(networkRequestTool);
});
afterAll(async () => {
await registry.dispose();
await new Promise<void>((resolve) => {
wss.close(() => {
mockServer.close(() => resolve());
});
});
});
describe("NetworkInspector integration (mock server)", () => {
it("shares the same CDP connection with JsRuntimeDebugger (only 1 WS connection)", async () => {
// Reset counter before this test group starts.
// The beforeAll already sets up the server but doesn't create any services yet.
wsConnectionCount = 0;
// Resolve the network inspector, which should trigger JsRuntimeDebugger first
await registry.resolveService(`NetworkInspector:${mockPort}:mock-device`);
// Only ONE WebSocket connection should have been opened
expect(wsConnectionCount).toBe(1);
// Verify both services are running
expect(registry.getServiceState(`JsRuntimeDebugger:${mockPort}:mock-device`)).toBe("RUNNING");
expect(registry.getServiceState(`NetworkInspector:${mockPort}:mock-device`)).toBe("RUNNING");
});
it("view-network-logs returns paginated network entries", async () => {
const result = (await registry.invokeTool("view-network-logs", {
port: mockPort,
})) as string;
expect(result).toContain("NETWORK LOGS");
expect(result).toContain("rn-net-1");
expect(result).toContain("GET");
expect(result).toContain("/users");
expect(result).toContain("200 OK");
expect(result).toContain("rn-net-2");
});
it("view-network-logs returns 'no traffic' for empty log", async () => {
// Temporarily clear the log
const savedLog = networkLog.splice(0);
try {
const result = (await registry.invokeTool("view-network-logs", {
port: mockPort,
})) as string;
expect(result).toContain("No network traffic captured");
} finally {
// Restore the log
networkLog.push(...savedLog);
}
});
it("view-network-request-details returns full details for a known requestId", async () => {
const result = (await registry.invokeTool("view-network-request-details", {
port: mockPort,
requestId: "rn-net-1",
})) as Record<string, unknown>;
expect(result.requestId).toBe("rn-net-1");
expect(result.state).toBe("finished");
const req = result.request as Record<string, unknown>;
expect(req.url).toBe("https://api.example.com/users");
expect(req.method).toBe("GET");
const resp = result.response as Record<string, unknown>;
expect(resp.status).toBe(200);
expect(resp.statusText).toBe("OK");
// Verify sensitive headers are redacted
const respHeaders = resp.headers as Record<string, string>;
expect(respHeaders.authorization).toBe("[REDACTED]");
expect(respHeaders["content-type"]).toBe("application/json");
});
it("view-network-request-details returns response body when includeBody is true", async () => {
const result = (await registry.invokeTool("view-network-request-details", {
port: mockPort,
requestId: "rn-net-1",
includeBody: true,
})) as Record<string, unknown>;
const resp = result.response as Record<string, unknown>;
expect(resp.body).toBeDefined();
expect(resp.body).toContain("Alice");
expect(resp.body).toContain("Bob");
});
it("view-network-request-details returns error for unknown requestId", async () => {
const result = (await registry.invokeTool("view-network-request-details", {
port: mockPort,
requestId: "rn-net-999",
})) as string;
expect(result).toContain("Request not found");
});
it("still only 1 WS connection when JsRuntimeDebugger is resolved before NetworkInspector", async () => {
// Dispose everything first so we can start fresh
await registry.disposeService(`JsRuntimeDebugger:${mockPort}:mock-device`).catch(() => {});
wsConnectionCount = 0;
// Explicitly resolve JsRuntimeDebugger first
await registry.resolveService(`JsRuntimeDebugger:${mockPort}:mock-device`);
expect(wsConnectionCount).toBe(1);
// Now resolve NetworkInspector — should NOT open a second connection
await registry.resolveService(`NetworkInspector:${mockPort}:mock-device`);
expect(wsConnectionCount).toBe(1);
});
it("view-network-logs filters out requests to the Metro server port", async () => {
// Add a request that targets the Metro server itself
const metroEntry = {
id: networkLog.length,
requestId: "rn-net-metro",
state: "finished" as const,
request: {
url: `http://localhost:${mockPort}/symbolicate`,
method: "POST",
headers: {},
},
response: {
url: `http://localhost:${mockPort}/symbolicate`,
status: 200,
statusText: "OK",
headers: {},
mimeType: "application/json",
},
resourceType: "Fetch",
encodedDataLength: 100,
timestamp: Date.now() / 1000,
durationMs: 10,
};
networkLog.push(metroEntry);
try {
const result = (await registry.invokeTool("view-network-logs", {
port: mockPort,
})) as string;
// The Metro request should be filtered out
expect(result).not.toContain("rn-net-metro");
expect(result).not.toContain("/symbolicate");
// But non-Metro requests should still be present
expect(result).toContain("rn-net-1");
} finally {
// Remove the test entry
const idx = networkLog.findIndex((e) => e.requestId === "rn-net-metro");
if (idx >= 0) networkLog.splice(idx, 1);
}
});
it("view-network-logs returns page index out-of-range error", async () => {
const result = (await registry.invokeTool("view-network-logs", {
port: mockPort,
pageIndex: 999,
})) as string;
expect(result).toContain("Page index out of range");
});
it("view-network-request-details omits body when includeBody is false", async () => {
const result = (await registry.invokeTool("view-network-request-details", {
port: mockPort,
requestId: "rn-net-1",
includeBody: false,
})) as Record<string, unknown>;
const resp = result.response as Record<string, unknown>;
expect(resp).toBeDefined();
expect(resp.status).toBe(200);
// Body should NOT be included
expect(resp.body).toBeUndefined();
});
it("view-network-request-details truncates large response bodies", async () => {
// Add a request with a very large body (> 1000 chars)
const largeBody = "x".repeat(2000);
const largeEntry = {
id: networkLog.length,
requestId: "rn-net-large",
state: "finished" as const,
request: {
url: "https://api.example.com/large",
method: "GET",
headers: {},
},
response: {
url: "https://api.example.com/large",
status: 200,
statusText: "OK",
headers: {},
mimeType: "text/plain",
},
resourceType: "Fetch",
encodedDataLength: 2000,
timestamp: Date.now() / 1000,
durationMs: 100,
responseBody: largeBody,
};
networkLog.push(largeEntry);
try {
const result = (await registry.invokeTool("view-network-request-details", {
port: mockPort,
requestId: "rn-net-large",
includeBody: true,
})) as Record<string, unknown>;
const resp = result.response as Record<string, unknown>;
expect(resp.body).toBeDefined();
const body = resp.body as string;
expect(body).toContain("TRUNCATED");
expect(body).toContain("2000 chars");
// Body should be shorter than original
expect(body.length).toBeLessThan(2000);
} finally {
const idx = networkLog.findIndex((e) => e.requestId === "rn-net-large");
if (idx >= 0) networkLog.splice(idx, 1);
}
});
it("NetworkInspector cascades teardown when JsRuntimeDebugger is disposed", async () => {
// Dispose JsRuntimeDebugger — NetworkInspector should also be torn down
await registry.disposeService(`JsRuntimeDebugger:${mockPort}:mock-device`);
expect(registry.getServiceState(`JsRuntimeDebugger:${mockPort}:mock-device`)).toBe("IDLE");
expect(registry.getServiceState(`NetworkInspector:${mockPort}:mock-device`)).toBe("IDLE");
});
});