Files
cloakfetch-gateway/test/browser_integration.test.ts
2026-07-24 23:10:05 +08:00

306 lines
11 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { Fetcher } from "../src/fetcher.js";
import { NetworkHistory } from "../src/network_history.js";
import { RequestHistory } from "../src/request_history.js";
import { Semaphore } from "../src/semaphore.js";
import { buildServer } from "../src/server.js";
import { SourceSessionManager } from "../src/source_session_manager.js";
import type { AppConfig, NetworkRecord } from "../src/types.js";
import { WebSocketHub } from "../src/websocket_hub.js";
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64");
test("real Chromium source sessions and network observer", { timeout: 120_000 }, async (t) => {
const target = await startTargetServer();
const profileRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cloakfetch-test-profile-"));
const config = createConfig(profileRoot);
const history = new RequestHistory(config.historyLimit);
const networkHistory = new NetworkHistory(config.networkHistoryLimit);
let fetcher: Fetcher;
const hub = new WebSocketHub(config.websocketHeartbeatMs, () => ({
server_time: new Date().toISOString(),
stats: fetcher.stats,
}));
const sources = new SourceSessionManager(config, networkHistory, hub);
fetcher = new Fetcher(config, sources, new Semaphore(config.concurrency));
const gateway = await buildServer(config, fetcher, history, networkHistory, sources, hub);
await gateway.listen({ host: "127.0.0.1", port: 0 });
const gatewayBase = `http://127.0.0.1:${(gateway.server.address() as { port: number }).port}`;
t.after(async () => {
await gateway.close();
await sources.closeAll();
await target.close();
if (path.resolve(profileRoot).startsWith(os.tmpdir())) {
await fs.rm(profileRoot, { recursive: true, force: true });
}
});
const ws = new WebSocket(gatewayBase.replace("http://", "ws://") + "/v1/ws");
const wsMessages: unknown[] = [];
ws.onmessage = (event) => {
wsMessages.push(JSON.parse(event.data));
};
await waitFor(() => wsMessages.some((message) => (message as { type?: string }).type === "hello"));
await postJson(`${gatewayBase}/v1/sources`, {
id: "s1",
warmup_url: `${target.base}/set-cookie?value=a`,
viewport: { width: 1280, height: 720 },
});
await postJson(`${gatewayBase}/v1/sources`, {
id: "s2",
warmup_url: `${target.base}/set-cookie?value=b`,
});
const echo1 = await postFetchText(gatewayBase, `${target.base}/echo-cookie`, "s1");
const echo2 = await postFetchText(gatewayBase, `${target.base}/echo-cookie`, "s2");
assert.match(echo1, /session=a/);
assert.match(echo2, /session=b/);
await postJson(`${gatewayBase}/v1/diagnostics/fetch`, {
source_id: "s1",
url: `${target.base}/`,
timeout_ms: 30000,
});
await waitFor(() => hasPaths(networkHistory.recent(2000), ["/", "/script.js", "/style.css", "/image.png", "/frame", "/api?script=1"]));
assert.ok(target.hits.some((hit) => hit.pathname === "/api" && hit.searchParams.get("script") === "1"));
await postJson(`${gatewayBase}/v1/sources/s1/warmup`, {
url: `${target.base}/localstorage-check`,
timeout_ms: 30000,
});
await waitFor(() => target.hits.some((hit) => hit.pathname === "/api" && hit.searchParams.get("ls") === "a"));
await postJson(`${gatewayBase}/v1/sources/s2/warmup`, {
url: `${target.base}/localstorage-check`,
timeout_ms: 30000,
});
await waitFor(() => target.hits.some((hit) => hit.pathname === "/api" && hit.searchParams.get("ls") === "b"));
await postJson(`${gatewayBase}/v1/diagnostics/fetch`, {
source_id: "s1",
url: `${target.base}/redirect`,
timeout_ms: 30000,
});
await waitFor(() => networkHistory.recent(2000).some((record) => record.redirect_to?.endsWith("/image.png")));
await postJson(`${gatewayBase}/v1/diagnostics/fetch`, {
source_id: "s1",
url: `${target.base}/fail-page`,
timeout_ms: 30000,
});
await waitFor(() => networkHistory.recent(2000).some((record) => record.failure_text && record.url.endsWith("/fail.png")));
await postFetchText(gatewayBase, `${target.base}/cache-page`, "s1");
await postFetchText(gatewayBase, `${target.base}/cache-page`, "s1");
await waitFor(() => networkHistory.recent(2000).some((record) => record.url.endsWith("/cache.png") && record.from_cache));
await postJson(`${gatewayBase}/v1/diagnostics/fetch`, {
source_id: "s1",
url: `${target.base}/many`,
timeout_ms: 30000,
});
assert.ok(networkHistory.summary().retained_total > 20);
await waitFor(() => wsMessages.some((message) => (message as { type?: string }).type === "network.request"));
await waitFor(() => wsMessages.some((message) => (message as { type?: string }).type === "network.finished"));
const imageResponse = await fetch(`${gatewayBase}/v1/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ source_id: "s1", url: `${target.base}/image.png`, timeout_ms: 30000 }),
});
const imageBody = Buffer.from(await imageResponse.arrayBuffer());
assert.equal(imageResponse.status, 200);
assert.equal(imageBody[0], 0x89);
const pages = await fetchJson<unknown[]>(`${gatewayBase}/v1/sources/s1/pages`);
const cookies = await fetchJson<unknown[]>(`${gatewayBase}/v1/sources/s1/cookies`);
assert.ok(pages.length > 0);
assert.ok(cookies.length > 0);
await fetch(`${gatewayBase}/v1/sources/s2`, { method: "DELETE" });
const removed = await fetch(`${gatewayBase}/v1/sources/s2`);
assert.equal(removed.status, 404);
ws.close();
});
async function startTargetServer() {
const hits: URL[] = [];
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
hits.push(url);
if (url.pathname === "/") {
send(res, 200, "text/html", `<!doctype html><html><head><title>root</title><link rel="stylesheet" href="/style.css"></head><body><img src="/image.png"><iframe src="/frame"></iframe><script src="/script.js"></script></body></html>`);
return;
}
if (url.pathname === "/script.js") {
send(res, 200, "application/javascript", `window.scriptLoaded = true; fetch('/api?script=1').catch(() => {});`);
return;
}
if (url.pathname === "/style.css") {
send(res, 200, "text/css", "body { color: rgb(1, 2, 3); }");
return;
}
if (url.pathname === "/image.png") {
sendBuffer(res, 200, "image/png", PNG);
return;
}
if (url.pathname === "/frame") {
send(res, 200, "text/html", "<!doctype html><title>frame</title><p>frame</p>");
return;
}
if (url.pathname === "/api") {
send(res, 200, "application/json", JSON.stringify({ ok: true }));
return;
}
if (url.pathname === "/redirect") {
res.writeHead(302, { location: "/image.png" });
res.end();
return;
}
if (url.pathname === "/set-cookie") {
const value = url.searchParams.get("value") ?? "x";
res.writeHead(200, {
"content-type": "text/html",
"set-cookie": `session=${value}; Path=/`,
});
res.end(`<!doctype html><script>localStorage.setItem('session', ${JSON.stringify(value)});</script>`);
return;
}
if (url.pathname === "/echo-cookie") {
send(res, 200, "text/plain", req.headers.cookie ?? "");
return;
}
if (url.pathname === "/localstorage-check") {
send(res, 200, "text/html", "<!doctype html><script>fetch('/api?ls=' + encodeURIComponent(localStorage.getItem('session') || '')).catch(() => {});</script>");
return;
}
if (url.pathname === "/cache-page") {
send(res, 200, "text/html", "<!doctype html><img src=\"/cache.png\">");
return;
}
if (url.pathname === "/fail-page") {
send(res, 200, "text/html", "<!doctype html><img src=\"/fail.png\">");
return;
}
if (url.pathname === "/fail.png") {
req.socket.destroy();
return;
}
if (url.pathname === "/cache.png") {
if (req.headers["if-none-match"] === "\"cache-test\"") {
res.writeHead(304, { etag: "\"cache-test\"", "cache-control": "no-cache" });
res.end();
return;
}
res.writeHead(200, {
"content-type": "image/png",
etag: "\"cache-test\"",
"cache-control": "no-cache",
"x-cache": "HIT",
});
res.end(PNG);
return;
}
if (url.pathname === "/many") {
const images = Array.from({ length: 40 }, (_, index) => `<img src="/image.png?n=${index}">`).join("");
send(res, 200, "text/html", `<!doctype html>${images}<script>fetch('/api?many=1')</script>`);
return;
}
send(res, 404, "text/plain", "not found");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as { port: number };
return {
base: `http://127.0.0.1:${address.port}`,
hits,
close: async () => {
server.close();
await once(server, "close").catch(() => undefined);
},
};
}
function createConfig(profileDir: string): AppConfig {
return {
host: "127.0.0.1",
port: 0,
profileDir,
headless: true,
concurrency: 4,
timeoutMs: 30_000,
maxBodyBytes: 64 * 1024 * 1024,
historyLimit: 1000,
websocketHeartbeatMs: 1_000,
networkHistoryLimit: 5000,
networkBodyCaptureBytes: 262_144,
};
}
async function postFetchText(gatewayBase: string, url: string, sourceId: string): Promise<string> {
const response = await fetch(`${gatewayBase}/v1/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ source_id: sourceId, url, timeout_ms: 30000 }),
});
return response.text();
}
async function postJson<T = unknown>(url: string, body: unknown): Promise<T> {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`);
}
return response.json() as Promise<T>;
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`);
}
return response.json() as Promise<T>;
}
async function waitFor(predicate: () => boolean, timeoutMs = 20_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("waitFor timeout");
}
function hasPaths(records: NetworkRecord[], paths: string[]): boolean {
return paths.every((expected) => records.some((record) => {
try {
const url = new URL(record.url);
return `${url.pathname}${url.search}` === expected;
} catch {
return false;
}
}));
}
function send(res: http.ServerResponse, status: number, contentType: string, body: string): void {
res.writeHead(status, { "content-type": contentType });
res.end(body);
}
function sendBuffer(res: http.ServerResponse, status: number, contentType: string, body: Buffer): void {
res.writeHead(status, { "content-type": contentType });
res.end(body);
}