Skip to content

Commit cae3367

Browse files
authored
feat: add mock-upstream mode for fetch/http/nextjs e2e suites (#125)
1 parent 737c500 commit cae3367

14 files changed

Lines changed: 503 additions & 292 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
const http = require("http");
2+
const https = require("https");
3+
4+
const EXTERNAL_HTTP_TIMEOUT_MS = Number(process.env.EXTERNAL_HTTP_TIMEOUT_MS || "3000");
5+
const USE_MOCK_EXTERNALS = ["1", "true", "yes"].includes((process.env.USE_MOCK_EXTERNALS || "").toLowerCase());
6+
const MOCK_SERVER_BASE_URL = process.env.MOCK_SERVER_BASE_URL || "http://mock-upstream:8081";
7+
8+
function upstreamUrl(rawUrl) {
9+
if (!USE_MOCK_EXTERNALS) {
10+
return rawUrl;
11+
}
12+
const src = new URL(rawUrl);
13+
const base = new URL(MOCK_SERVER_BASE_URL);
14+
return `${base.origin}${src.pathname}${src.search}`;
15+
}
16+
17+
function withExternalTimeout(init = {}) {
18+
if (init.signal) {
19+
return init;
20+
}
21+
22+
const timeoutSignal = createTimeoutSignal(EXTERNAL_HTTP_TIMEOUT_MS);
23+
return {
24+
...init,
25+
...(timeoutSignal ? { signal: timeoutSignal } : {}),
26+
};
27+
}
28+
29+
function createTimeoutSignal(timeoutMs) {
30+
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
31+
return AbortSignal.timeout(timeoutMs);
32+
}
33+
34+
if (typeof AbortController === "undefined") {
35+
return undefined;
36+
}
37+
38+
const controller = new AbortController();
39+
const timer = setTimeout(() => controller.abort(), timeoutMs);
40+
if (typeof timer.unref === "function") {
41+
timer.unref();
42+
}
43+
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
44+
return controller.signal;
45+
}
46+
47+
function resolveClient(target) {
48+
return target.protocol === "https:" ? https : http;
49+
}
50+
51+
function getExternalHttpTimeoutMs() {
52+
return EXTERNAL_HTTP_TIMEOUT_MS;
53+
}
54+
55+
function getTextViaNode(rawUrl) {
56+
const target = new URL(upstreamUrl(rawUrl.toString()));
57+
const client = resolveClient(target);
58+
return new Promise((resolve, reject) => {
59+
client
60+
.get(
61+
target,
62+
{
63+
timeout: EXTERNAL_HTTP_TIMEOUT_MS,
64+
},
65+
(response) => {
66+
let data = "";
67+
response.on("data", (chunk) => {
68+
data += chunk;
69+
});
70+
response.on("end", () => resolve(data));
71+
},
72+
)
73+
.on("error", reject);
74+
});
75+
}
76+
77+
function requestTextViaNode(rawUrl, method, body) {
78+
const target = new URL(upstreamUrl(rawUrl.toString()));
79+
const client = resolveClient(target);
80+
return new Promise((resolve, reject) => {
81+
const request = client.request(
82+
{
83+
protocol: target.protocol,
84+
hostname: target.hostname,
85+
port: target.port ? Number(target.port) : target.protocol === "https:" ? 443 : 80,
86+
path: `${target.pathname}${target.search}`,
87+
method,
88+
headers: {
89+
"Content-Type": "application/json",
90+
},
91+
timeout: EXTERNAL_HTTP_TIMEOUT_MS,
92+
},
93+
(response) => {
94+
let data = "";
95+
response.on("data", (chunk) => {
96+
data += chunk;
97+
});
98+
response.on("end", () => resolve(data));
99+
},
100+
);
101+
102+
request.on("error", reject);
103+
if (body) {
104+
request.write(body);
105+
}
106+
request.end();
107+
});
108+
}
109+
110+
module.exports = {
111+
upstreamUrl,
112+
withExternalTimeout,
113+
getExternalHttpTimeoutMs,
114+
getTextViaNode,
115+
requestTextViaNode,
116+
};
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env node
2+
3+
const http = require("http");
4+
const { URL } = require("url");
5+
6+
const port = Number(process.env.MOCK_UPSTREAM_PORT || "8081");
7+
8+
function sendJson(res, payload, status = 200) {
9+
const body = Buffer.from(JSON.stringify(payload));
10+
res.writeHead(status, {
11+
"Content-Type": "application/json",
12+
"Content-Length": String(body.length),
13+
});
14+
res.end(body);
15+
}
16+
17+
function sendText(res, payload, status = 200) {
18+
const body = Buffer.from(payload, "utf-8");
19+
res.writeHead(status, {
20+
"Content-Type": "text/plain; charset=utf-8",
21+
"Content-Length": String(body.length),
22+
});
23+
res.end(body);
24+
}
25+
26+
function readBody(req) {
27+
return new Promise((resolve) => {
28+
const chunks = [];
29+
req.on("data", (chunk) => chunks.push(chunk));
30+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
31+
req.on("error", () => resolve(""));
32+
});
33+
}
34+
35+
function mockPost(id) {
36+
return { id, title: `Mock Post ${id}`, body: `Body for post ${id}`, userId: ((id - 1) % 10) + 1 };
37+
}
38+
39+
const server = http.createServer(async (req, res) => {
40+
const url = new URL(req.url || "/", `http://localhost:${port}`);
41+
const path = url.pathname;
42+
const method = req.method || "GET";
43+
44+
if (path === "/health") {
45+
return sendJson(res, { status: "ok" });
46+
}
47+
48+
if (method === "GET" && path === "/posts/1") {
49+
return sendJson(res, mockPost(1));
50+
}
51+
52+
if (method === "GET" && path === "/posts") {
53+
const limit = Number(url.searchParams.get("_limit") || "5");
54+
const posts = Array.from({ length: limit }, (_, i) => mockPost(i + 1));
55+
return sendJson(res, posts);
56+
}
57+
58+
if (method === "GET" && path === "/users") {
59+
return sendJson(
60+
res,
61+
Array.from({ length: 10 }, (_, i) => ({
62+
id: i + 1,
63+
name: `User ${i + 1}`,
64+
username: `user${i + 1}`,
65+
email: `user${i + 1}@example.com`,
66+
})),
67+
);
68+
}
69+
70+
if (method === "POST" && path === "/posts") {
71+
const raw = await readBody(req);
72+
let parsed = {};
73+
try {
74+
parsed = raw ? JSON.parse(raw) : {};
75+
} catch {
76+
parsed = {};
77+
}
78+
return sendJson(
79+
res,
80+
{
81+
id: 101,
82+
title: parsed.title || "mock-title",
83+
body: parsed.body || "",
84+
userId: parsed.userId || 1,
85+
test: parsed.test || undefined,
86+
},
87+
201,
88+
);
89+
}
90+
91+
if (method === "GET" && path === "/robots.txt") {
92+
return sendText(res, "User-agent: *\nDisallow: /deny\n");
93+
}
94+
95+
if (method === "GET" && url.searchParams.get("format") === "j1") {
96+
const location = decodeURIComponent(path.replace(/^\/+/, "") || "San Francisco");
97+
return sendJson(res, {
98+
current_condition: [
99+
{
100+
temp_F: "72",
101+
humidity: "55",
102+
localObsDateTime: "2026-02-26 07:00 PM",
103+
weatherDesc: [{ value: `Clear (${location})` }],
104+
pressure: "1015",
105+
},
106+
],
107+
});
108+
}
109+
110+
return sendJson(res, { error: `No mock route for ${method} ${path}` }, 404);
111+
});
112+
113+
server.listen(port, "0.0.0.0", () => {
114+
console.log(`Mock upstream listening on :${port}`);
115+
});

src/instrumentation/libraries/fetch/e2e-tests/cjs-fetch/docker-compose.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
services:
2+
mock-upstream:
3+
image: node:18
4+
command: ["node", "/mock/mock-server.js"]
5+
working_dir: /mock
6+
volumes:
7+
- ../../../e2e-common/mock-upstream:/mock:ro
8+
29
app:
310
build:
411
context: ../../../../../..
@@ -12,11 +19,16 @@ services:
1219
- BENCHMARKS=${BENCHMARKS:-}
1320
- BENCHMARK_DURATION=${BENCHMARK_DURATION:-5}
1421
- BENCHMARK_WARMUP=${BENCHMARK_WARMUP:-3}
22+
- USE_MOCK_EXTERNALS=${USE_MOCK_EXTERNALS:-1}
23+
- MOCK_SERVER_BASE_URL=${MOCK_SERVER_BASE_URL:-http://mock-upstream:8081}
24+
- EXTERNAL_HTTP_TIMEOUT_MS=${EXTERNAL_HTTP_TIMEOUT_MS:-3000}
1525
volumes:
1626
# Mount SDK source for the SDK dependency
1727
- ../../../../../..:/sdk:ro
1828
# Mount app source
1929
- ./src:/app/src
2030
# Mount .tusk config (but traces/logs are created inside container)
2131
- ./.tusk/config.yaml:/app/.tusk/config.yaml:ro
32+
depends_on:
33+
- mock-upstream
2234
working_dir: /app

src/instrumentation/libraries/fetch/e2e-tests/cjs-fetch/src/index.ts

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { TuskDrift } from "./tdInit";
22
import express, { Request, Response } from "express";
3+
const { upstreamUrl, withExternalTimeout } = require(
4+
"/sdk/src/instrumentation/libraries/e2e-common/external-http.cjs",
5+
);
36

47
const app = express();
58
const PORT = process.env.PORT || 3000;
@@ -9,7 +12,10 @@ app.use(express.json());
912
// Test endpoint using fetch GET
1013
app.get("/test/fetch-get", async (req: Request, res: Response) => {
1114
try {
12-
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
15+
const response = await fetch(
16+
upstreamUrl("https://jsonplaceholder.typicode.com/posts/1"),
17+
withExternalTimeout(),
18+
);
1319
const data = await response.json();
1420

1521
res.json({
@@ -31,13 +37,16 @@ app.get("/test/fetch-get", async (req: Request, res: Response) => {
3137
// Test endpoint using fetch POST
3238
app.post("/test/fetch-post", async (req: Request, res: Response) => {
3339
try {
34-
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
35-
method: "POST",
36-
headers: {
37-
"Content-Type": "application/json",
38-
},
39-
body: JSON.stringify(req.body),
40-
});
40+
const response = await fetch(
41+
upstreamUrl("https://jsonplaceholder.typicode.com/posts"),
42+
withExternalTimeout({
43+
method: "POST",
44+
headers: {
45+
"Content-Type": "application/json",
46+
},
47+
body: JSON.stringify(req.body),
48+
}),
49+
);
4150

4251
const data = await response.json();
4352

@@ -58,14 +67,17 @@ app.post("/test/fetch-post", async (req: Request, res: Response) => {
5867
// Test endpoint using fetch with custom headers
5968
app.get("/test/fetch-headers", async (req: Request, res: Response) => {
6069
try {
61-
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1", {
62-
method: "GET",
63-
headers: {
64-
"User-Agent": "TuskDrift-Test/1.0",
65-
"X-Custom-Header": "test-value",
66-
Accept: "application/json",
67-
},
68-
});
70+
const response = await fetch(
71+
upstreamUrl("https://jsonplaceholder.typicode.com/posts/1"),
72+
withExternalTimeout({
73+
method: "GET",
74+
headers: {
75+
"User-Agent": "TuskDrift-Test/1.0",
76+
"X-Custom-Header": "test-value",
77+
Accept: "application/json",
78+
},
79+
}),
80+
);
6981

7082
const data = await response.json();
7183

@@ -91,7 +103,10 @@ app.get("/test/fetch-headers", async (req: Request, res: Response) => {
91103
// Test endpoint using fetch with JSON response
92104
app.get("/test/fetch-json", async (req: Request, res: Response) => {
93105
try {
94-
const response = await fetch("https://jsonplaceholder.typicode.com/users");
106+
const response = await fetch(
107+
upstreamUrl("https://jsonplaceholder.typicode.com/users"),
108+
withExternalTimeout(),
109+
);
95110

96111
if (!response.ok) {
97112
throw new Error(`HTTP error! status: ${response.status}`);
@@ -116,10 +131,10 @@ app.get("/test/fetch-json", async (req: Request, res: Response) => {
116131
// Test endpoint using fetch with URL object
117132
app.get("/test/fetch-url-object", async (req: Request, res: Response) => {
118133
try {
119-
const url = new URL("https://jsonplaceholder.typicode.com/posts");
134+
const url = new URL(upstreamUrl("https://jsonplaceholder.typicode.com/posts"));
120135
url.searchParams.append("_limit", "5");
121136

122-
const response = await fetch(url);
137+
const response = await fetch(url, withExternalTimeout());
123138
const data = await response.json();
124139

125140
res.json({

src/instrumentation/libraries/fetch/e2e-tests/esm-fetch/docker-compose.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
services:
2+
mock-upstream:
3+
image: node:18
4+
command: ["node", "/mock/mock-server.js"]
5+
working_dir: /mock
6+
volumes:
7+
- ../../../e2e-common/mock-upstream:/mock:ro
8+
29
app:
310
build:
411
context: ../../../../../..
@@ -12,11 +19,16 @@ services:
1219
- BENCHMARKS=${BENCHMARKS:-}
1320
- BENCHMARK_DURATION=${BENCHMARK_DURATION:-5}
1421
- BENCHMARK_WARMUP=${BENCHMARK_WARMUP:-3}
22+
- USE_MOCK_EXTERNALS=${USE_MOCK_EXTERNALS:-1}
23+
- MOCK_SERVER_BASE_URL=${MOCK_SERVER_BASE_URL:-http://mock-upstream:8081}
24+
- EXTERNAL_HTTP_TIMEOUT_MS=${EXTERNAL_HTTP_TIMEOUT_MS:-3000}
1525
volumes:
1626
# Mount SDK source for hot reload (this is what package.json expects)
1727
- ../../../../../..:/sdk:ro
1828
# Mount .tusk config
1929
- ./.tusk/config.yaml:/app/.tusk/config.yaml:ro
2030
# Mount app source for development
2131
- ./src:/app/src
32+
depends_on:
33+
- mock-upstream
2234
working_dir: /app

0 commit comments

Comments
 (0)