test(mock-api): add fuzzy socket-aware harness (#1963)

This commit is contained in:
Steven Enamakel
2026-05-16 14:33:21 -07:00
committed by GitHub
parent 40cd7c4d0d
commit 32d09e7877
20 changed files with 2471 additions and 250 deletions
+74
View File
@@ -1,13 +1,30 @@
import { json } from "./http.mjs";
import { resetConversationFixturesState } from "./routes/conversations.mjs";
import { resetCronFixturesState } from "./routes/cron.mjs";
import {
clearSocketEventLog,
clearRequestLog,
getSocketEventLog,
listSocketSessions,
getMockBehavior,
getMockConversations,
getMockCronJobs,
getMockMessages,
listMockLlmThreads,
getMockWebhookTriggers,
getRequestLog,
resetMockBehavior,
resetMockConversations,
resetMockCronJobs,
resetMockMessages,
resetMockLlmThreads,
resetSocketSessions,
resetMockTunnels,
resetMockWebhookTriggers,
setMockBehavior,
setMockBehaviors,
} from "./state.mjs";
import { disconnectMockSockets, emitMockSocketEvent } from "./socket.mjs";
export function handleAdmin(ctx) {
const { method, url, parsedBody, res, getPort } = ctx;
@@ -24,12 +41,44 @@ export function handleAdmin(ctx) {
json(res, 200, { success: true, data: getMockBehavior() });
return true;
}
if (method === "GET" && /^\/__admin\/state\/?$/.test(url)) {
json(res, 200, {
success: true,
data: {
requestCount: getRequestLog().length,
conversations: getMockConversations(),
messages: getMockMessages(),
cronJobs: getMockCronJobs(),
webhookTriggers: getMockWebhookTriggers(),
llmThreads: listMockLlmThreads(),
socketSessions: listSocketSessions(),
socketEventCount: getSocketEventLog().length,
},
});
return true;
}
if (method === "GET" && /^\/__admin\/socket\/sessions\/?$/.test(url)) {
json(res, 200, { success: true, data: listSocketSessions() });
return true;
}
if (method === "GET" && /^\/__admin\/socket\/events\/?$/.test(url)) {
json(res, 200, { success: true, data: getSocketEventLog() });
return true;
}
if (method === "POST" && /^\/__admin\/reset\/?$/.test(url)) {
const keepBehavior = parsedBody?.keepBehavior === true;
const keepRequests = parsedBody?.keepRequests === true;
if (!keepBehavior) resetMockBehavior();
if (!keepRequests) clearRequestLog();
resetMockTunnels();
resetMockConversations();
resetMockMessages();
resetMockCronJobs();
resetMockWebhookTriggers();
resetMockLlmThreads();
resetConversationFixturesState();
resetCronFixturesState();
resetSocketSessions();
json(res, 200, {
success: true,
data: {
@@ -48,5 +97,30 @@ export function handleAdmin(ctx) {
json(res, 200, { success: true, data: getMockBehavior() });
return true;
}
if (method === "POST" && /^\/__admin\/socket\/emit\/?$/.test(url)) {
const delivered = emitMockSocketEvent({
event: parsedBody?.event,
data: parsedBody?.data,
targetSid: parsedBody?.targetSid,
targetUserId: parsedBody?.targetUserId,
excludeSid: parsedBody?.excludeSid,
delayMs: parsedBody?.delayMs,
});
json(res, 200, { success: true, data: { delivered } });
return true;
}
if (method === "POST" && /^\/__admin\/socket\/disconnect\/?$/.test(url)) {
const disconnected = disconnectMockSockets({
targetSid: parsedBody?.targetSid,
targetUserId: parsedBody?.targetUserId,
});
json(res, 200, { success: true, data: { disconnected } });
return true;
}
if (method === "POST" && /^\/__admin\/socket\/clear-events\/?$/.test(url)) {
clearSocketEventLog();
json(res, 200, { success: true, data: [] });
return true;
}
return false;
}
+11 -1
View File
@@ -8,13 +8,23 @@
* The legacy entrypoint at `scripts/mock-api-core.mjs` is a re-export shim
* over this module so existing import paths keep working.
*/
export { getMockServerPort, startMockServer, stopMockServer } from "./server.mjs";
export {
getMockServerPort,
startMockServer,
stopMockServer,
} from "./server.mjs";
export {
DEFAULT_PORT,
clearSocketEventLog,
clearRequestLog,
getSocketEventLog,
getMockBehavior,
listMockLlmThreads,
getRequestLog,
listSocketSessions,
resetMockBehavior,
resetMockLlmThreads,
setMockBehavior,
setMockBehaviors,
} from "./state.mjs";
export { disconnectMockSockets, emitMockSocketEvent } from "./socket.mjs";
+175 -6
View File
@@ -2,7 +2,13 @@ import test from "node:test";
import assert from "node:assert/strict";
import { handleLlmCompletions } from "../llm.mjs";
import { resetMockBehavior, setMockBehaviors } from "../../state.mjs";
import { handleIntegrations } from "../integrations.mjs";
import {
listMockLlmThreads,
resetMockBehavior,
resetMockLlmThreads,
setMockBehaviors,
} from "../../state.mjs";
function createMockResponse() {
return {
@@ -33,7 +39,10 @@ function createMockResponse() {
function makeCtx({
method = "POST",
url = "/chat/completions",
parsedBody = { model: "gpt-oss", messages: [{ role: "user", content: "hello" }] },
parsedBody = {
model: "gpt-oss",
messages: [{ role: "user", content: "hello" }],
},
headers = {},
} = {}) {
return {
@@ -47,6 +56,7 @@ function makeCtx({
test.beforeEach(() => {
resetMockBehavior();
resetMockLlmThreads();
});
test("handles root chat completions path with default fallback", () => {
@@ -58,10 +68,7 @@ test("handles root chat completions path with default fallback", () => {
assert.equal(ctx.res.statusCode, 200);
const body = JSON.parse(ctx.res.body);
assert.equal(body.model, "gpt-oss");
assert.equal(
body.choices[0].message.content,
"Hello from e2e mock agent",
);
assert.equal(body.choices[0].message.content, "Hello from e2e mock agent");
});
test("matches request rules against path and authorization header", () => {
@@ -170,3 +177,165 @@ test("returns false for non-LLM routes", () => {
const ctx = makeCtx({ method: "GET", url: "/chat/completions" });
assert.equal(handleLlmCompletions(ctx), false);
});
test("streams reasoning deltas for reasoning-family models", async () => {
const ctx = makeCtx({
url: "/chat/completions",
parsedBody: {
model: "openhuman-reasoning-mock",
stream: true,
messages: [{ role: "user", content: "compare these rollout options" }],
},
});
assert.equal(handleLlmCompletions(ctx), true);
await new Promise((resolve) => setTimeout(resolve, 220));
assert.match(ctx.res.body, /reasoning_content/);
assert.match(ctx.res.body, /Recommendation:/);
assert.match(ctx.res.body, /data: \[DONE\]/);
});
test("returns tool calls for agentic models and resolves follow-up turns", () => {
const first = makeCtx({
parsedBody: {
model: "openhuman-agentic-mock",
mockThreadId: "agent-thread-1",
messages: [
{ role: "user", content: "search the release notes and report back" },
],
tools: [{ type: "function", function: { name: "web_search" } }],
},
});
assert.equal(handleLlmCompletions(first), true);
const firstBody = JSON.parse(first.res.body);
assert.equal(firstBody.choices[0].finish_reason, "tool_calls");
assert.equal(
firstBody.choices[0].message.tool_calls[0].function.name,
"web_search",
);
const second = makeCtx({
parsedBody: {
model: "openhuman-agentic-mock",
mockThreadId: "agent-thread-1",
messages: [
{ role: "user", content: "search the release notes and report back" },
{
role: "tool",
content:
"Release notes mention Socket.IO support and dynamic mock routes.",
},
{ role: "user", content: "okay now summarize the result" },
],
},
});
assert.equal(handleLlmCompletions(second), true);
const secondBody = JSON.parse(second.res.body);
assert.match(
secondBody.choices[0].message.content,
/Socket\.IO support and dynamic mock routes/i,
);
});
test("updates coding responses across turns with thread memory", () => {
const first = makeCtx({
parsedBody: {
model: "gpt-5-codex-mock",
mockThreadId: "code-thread-1",
messages: [{ role: "user", content: "write a tiny typescript helper" }],
},
});
assert.equal(handleLlmCompletions(first), true);
const firstBody = JSON.parse(first.res.body);
assert.match(firstBody.choices[0].message.content, /```ts/);
const second = makeCtx({
parsedBody: {
model: "gpt-5-codex-mock",
mockThreadId: "code-thread-1",
messages: [
{ role: "user", content: "make it async and keep it in typescript" },
],
},
});
assert.equal(handleLlmCompletions(second), true);
const secondBody = JSON.parse(second.res.body);
assert.match(secondBody.choices[0].message.content, /Updated TS version/i);
assert.match(secondBody.choices[0].message.content, /async function runTask/);
});
test("shortens summarization responses across turns", () => {
const first = makeCtx({
parsedBody: {
model: "openhuman-summary-mock",
mockThreadId: "summary-thread-1",
messages: [
{
role: "user",
content:
"Summarize this: the mock backend now supports stateful routes, socket sessions, fault injection, and more realistic provider flows.",
},
],
},
});
assert.equal(handleLlmCompletions(first), true);
const firstBody = JSON.parse(first.res.body);
const second = makeCtx({
parsedBody: {
model: "openhuman-summary-mock",
mockThreadId: "summary-thread-1",
messages: [{ role: "user", content: "shorter" }],
},
});
assert.equal(handleLlmCompletions(second), true);
const secondBody = JSON.parse(second.res.body);
assert.ok(
secondBody.choices[0].message.content.length <=
firstBody.choices[0].message.content.length,
);
});
test("lists multiple mock model families from the integrations catalog", () => {
const ctx = {
method: "GET",
url: "/openai/v1/models",
parsedBody: null,
res: createMockResponse(),
};
assert.equal(handleIntegrations(ctx), true);
assert.equal(ctx.res.statusCode, 200);
const body = JSON.parse(ctx.res.body);
const ids = body.data.map((item) => item.id);
assert.ok(ids.includes("openhuman-reasoning-mock"));
assert.ok(ids.includes("openhuman-agentic-mock"));
assert.ok(ids.includes("gpt-5-codex-mock"));
assert.ok(ids.includes("openhuman-summary-mock"));
});
test("records thread state for multi-turn mock LLM sessions", () => {
const ctx = makeCtx({
parsedBody: {
model: "openhuman-summary-mock",
mockThreadId: "thread-state-1",
messages: [
{ role: "user", content: "summarize the latest provider status" },
],
},
});
assert.equal(handleLlmCompletions(ctx), true);
const threads = listMockLlmThreads();
const thread = threads.find((entry) => entry.key === "thread-state-1");
assert.ok(thread);
assert.equal(thread.lastFamily, "summarization");
assert.equal(thread.turnCount, 1);
});
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
resetMockBehavior,
setMockBehaviors,
startMockServer,
stopMockServer,
} from "../../index.mjs";
test.beforeEach(async () => {
await stopMockServer();
resetMockBehavior();
});
test.afterEach(async () => {
await stopMockServer();
});
test("persists created conversations across requests", async () => {
const started = await startMockServer(18575, { retryIfInUse: true });
const baseUrl = `http://127.0.0.1:${started.port}`;
const createdResponse = await fetch(`${baseUrl}/conversations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Fixture conversation", channel: "web" }),
});
assert.equal(createdResponse.status, 200);
const createdBody = await createdResponse.json();
const createdId = createdBody?.data?.id;
assert.equal(typeof createdId, "string");
const listResponse = await fetch(`${baseUrl}/conversations`);
const listBody = await listResponse.json();
assert.equal(Array.isArray(listBody?.data), true);
assert.equal(
listBody.data.some(
(entry) =>
entry.id === createdId && entry.title === "Fixture conversation",
),
true,
);
const detailResponse = await fetch(`${baseUrl}/conversations/${createdId}`);
const detailBody = await detailResponse.json();
assert.equal(detailBody?.data?.id, createdId);
assert.equal(Array.isArray(detailBody?.data?.messages), true);
});
test("does not reseed conversations after deleting everything until reset", async () => {
const started = await startMockServer(18577, { retryIfInUse: true });
const baseUrl = `http://127.0.0.1:${started.port}`;
const initialList = await fetch(`${baseUrl}/conversations`);
const initialBody = await initialList.json();
const ids = Array.isArray(initialBody?.data)
? initialBody.data.map((entry) => entry.id)
: [];
for (const id of ids) {
await fetch(`${baseUrl}/conversations/${id}`, { method: "DELETE" });
}
const afterDelete = await fetch(`${baseUrl}/conversations`);
const afterDeleteBody = await afterDelete.json();
assert.deepEqual(afterDeleteBody?.data, []);
});
test("keeps server-controlled ids on create and patch routes", async () => {
const started = await startMockServer(18578, { retryIfInUse: true });
const baseUrl = `http://127.0.0.1:${started.port}`;
const createdConversation = await fetch(`${baseUrl}/conversations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: "client-conv", title: "Client title" }),
}).then((response) => response.json());
assert.notEqual(createdConversation?.data?.id, "client-conv");
const createdCron = await fetch(`${baseUrl}/settings/cron-jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: "client-cron", name: "Client cron" }),
}).then((response) => response.json());
const cronId = createdCron?.data?.id;
assert.notEqual(cronId, "client-cron");
await fetch(`${baseUrl}/settings/cron-jobs/${cronId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: "mutated-cron", enabled: false }),
});
const cronList = await fetch(`${baseUrl}/settings/cron-jobs`).then(
(response) => response.json(),
);
const patchedCron = cronList.data.find((entry) => entry.id === cronId);
assert.equal(patchedCron.id, cronId);
assert.equal(patchedCron.enabled, false);
});
test("applies injected HTTP fault rules without route-specific controller logic", async () => {
const started = await startMockServer(18576, { retryIfInUse: true });
const baseUrl = `http://127.0.0.1:${started.port}`;
setMockBehaviors(
{
httpFaultRules: JSON.stringify([
{
method: "GET",
pathRegex: "^/auth/me(?:\\?.*)?$",
status: 503,
body: { success: false, error: "Synthetic outage" },
},
]),
},
"replace",
);
const response = await fetch(`${baseUrl}/auth/me`);
assert.equal(response.status, 503);
const body = await response.json();
assert.equal(body.error, "Synthetic outage");
});
+124 -18
View File
@@ -1,26 +1,106 @@
import { json } from "../http.mjs";
import {
behavior,
createMockId,
fuzzyNumber,
fuzzyPick,
fuzzyTimestamp,
getMockConversations,
getMockMessages,
} from "../state.mjs";
let fixturesSeeded = false;
export function resetConversationFixturesState() {
fixturesSeeded = false;
}
function ensureConversationFixtures() {
if (fixturesSeeded) return;
const conversations = getMockConversations();
const messages = getMockMessages();
if (conversations.length > 0 || messages.length > 0) {
fixturesSeeded = true;
return;
}
const mockBehavior = behavior();
const count = Math.max(
0,
Number(mockBehavior.conversationCount || fuzzyNumber("conv:count", 1, 4)),
);
const titles = [
"Daily standup",
"Onboarding thread",
"Billing follow-up",
"Release prep",
"Research scratchpad",
];
for (let i = 0; i < count; i += 1) {
const id = createMockId("conv");
conversations.push({
id,
title: fuzzyPick(`conv:title:${i}`, titles, "Mock Conversation"),
channel: fuzzyPick(
`conv:channel:${i}`,
["web", "telegram", "discord"],
"web",
),
unreadCount: fuzzyNumber(`conv:unread:${i}`, 0, 3),
archived: false,
createdAt: fuzzyTimestamp(`conv:created:${i}`),
updatedAt: fuzzyTimestamp(`conv:updated:${i}`, 3 * 24 * 60 * 60 * 1000),
});
const messageCount = fuzzyNumber(`conv:messages:${i}`, 1, 3);
for (let j = 0; j < messageCount; j += 1) {
messages.push({
id: createMockId("msg"),
conversationId: id,
role: j % 2 === 0 ? "user" : "assistant",
text:
j % 2 === 0
? `Mock prompt ${j + 1} for ${id}`
: `Mock response ${j + 1} for ${id}`,
createdAt: fuzzyTimestamp(`msg:${i}:${j}`, 2 * 24 * 60 * 60 * 1000),
});
}
}
fixturesSeeded = true;
}
// Gap fill: conversations / messages / channels / notifications.
//
// The real backend serves rich, paginated data here; for e2e we return empty
// lists wrapped in the standard envelope. Specs that need richer fixtures can
// override via `setMockBehavior` and a future scenario knob.
export function handleConversations(ctx) {
const { method, url, parsedBody, res } = ctx;
ensureConversationFixtures();
const conversations = getMockConversations();
const messages = getMockMessages();
// /conversations
if (method === "GET" && /^\/conversations\/?(\?.*)?$/.test(url)) {
json(res, 200, { success: true, data: [] });
json(res, 200, { success: true, data: conversations });
return true;
}
if (method === "POST" && /^\/conversations\/?$/.test(url)) {
const created = {
...(parsedBody || {}),
id: createMockId("conv"),
title:
typeof parsedBody?.title === "string" && parsedBody.title.trim()
? parsedBody.title.trim()
: "Untitled mock conversation",
channel: parsedBody?.channel || "web",
unreadCount: 0,
archived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
conversations.unshift(created);
json(res, 200, {
success: true,
data: {
id: "conv_mock_" + Date.now(),
...(parsedBody || {}),
createdAt: new Date().toISOString(),
},
data: created,
});
return true;
}
@@ -28,14 +108,32 @@ export function handleConversations(ctx) {
/^\/conversations\/([^/?]+)\/?(\?.*)?$/,
);
if (conversationItemMatch) {
const conversation = conversations.find(
(entry) => entry.id === conversationItemMatch[1],
);
if (method === "GET") {
json(res, 200, {
success: true,
data: { id: conversationItemMatch[1], messages: [] },
data: {
...(conversation || {
id: conversationItemMatch[1],
title: "Mock Conversation",
channel: "web",
}),
messages: messages.filter(
(entry) => entry.conversationId === conversationItemMatch[1],
),
},
});
return true;
}
if (method === "DELETE") {
const index = conversations.findIndex(
(entry) => entry.id === conversationItemMatch[1],
);
if (index >= 0) {
conversations.splice(index, 1);
}
json(res, 200, { success: true, data: { deleted: true } });
return true;
}
@@ -54,17 +152,25 @@ export function handleConversations(ctx) {
return true;
}
if (method === "GET" && /^\/messages\/?(\?.*)?$/.test(url)) {
json(res, 200, { success: true, data: [] });
json(res, 200, { success: true, data: messages });
return true;
}
if (method === "POST" && /^\/messages\/?$/.test(url)) {
const created = {
...(parsedBody || {}),
id: createMockId("msg"),
conversationId: parsedBody?.conversationId || null,
role: parsedBody?.role || "user",
text:
parsedBody?.text ||
parsedBody?.content ||
"Synthetic mock message created by the test harness",
createdAt: new Date().toISOString(),
};
messages.push(created);
json(res, 200, {
success: true,
data: {
id: "msg_mock_" + Date.now(),
...(parsedBody || {}),
createdAt: new Date().toISOString(),
},
data: created,
});
return true;
}
+97 -15
View File
@@ -1,28 +1,97 @@
import { json } from "../http.mjs";
import {
createMockId,
fuzzyNumber,
fuzzyPick,
fuzzyTimestamp,
getMockCronJobs,
getMockWebhookTriggers,
} from "../state.mjs";
let fixturesSeeded = false;
export function resetCronFixturesState() {
fixturesSeeded = false;
}
function ensureCronFixtures() {
if (fixturesSeeded) return;
const cronJobs = getMockCronJobs();
const webhookTriggers = getMockWebhookTriggers();
if (cronJobs.length === 0) {
const count = fuzzyNumber("cron:count", 0, 2);
for (let i = 0; i < count; i += 1) {
cronJobs.push({
id: createMockId("cron"),
name: fuzzyPick(
`cron:name:${i}`,
["Morning digest", "Support sweep", "Usage snapshot"],
"Mock cron job",
),
schedule: fuzzyPick(
`cron:schedule:${i}`,
["0 8 * * *", "*/30 * * * *", "15 17 * * 1-5"],
"0 8 * * *",
),
enabled: i % 2 === 0,
createdAt: fuzzyTimestamp(`cron:created:${i}`),
});
}
}
if (webhookTriggers.length === 0) {
const count = fuzzyNumber("trg:count", 0, 2);
for (let i = 0; i < count; i += 1) {
webhookTriggers.push({
id: createMockId("trg"),
name: fuzzyPick(
`trg:name:${i}`,
["Github push", "Stripe paid", "CRM sync"],
"Mock trigger",
),
enabled: true,
createdAt: fuzzyTimestamp(`trg:created:${i}`),
});
}
}
fixturesSeeded = true;
}
// Gap fill: cron-job and webhook-trigger configuration endpoints stored on
// the user's settings document. The real backend persists arrays; mock just
// returns empty lists and accepts writes as no-ops.
export function handleCron(ctx) {
const { method, url, parsedBody, res } = ctx;
ensureCronFixtures();
const cronJobs = getMockCronJobs();
const webhookTriggers = getMockWebhookTriggers();
if (method === "GET" && /^\/settings\/cron-jobs\/?(\?.*)?$/.test(url)) {
json(res, 200, { success: true, data: [] });
json(res, 200, { success: true, data: cronJobs });
return true;
}
if (method === "POST" && /^\/settings\/cron-jobs\/?$/.test(url)) {
const created = {
...(parsedBody || {}),
id: createMockId("cron"),
createdAt: new Date().toISOString(),
};
cronJobs.unshift(created);
json(res, 200, {
success: true,
data: {
id: "cron_mock_" + Date.now(),
...(parsedBody || {}),
createdAt: new Date().toISOString(),
},
data: created,
});
return true;
}
const cronItem = url.match(/^\/settings\/cron-jobs\/([^/?]+)\/?(\?.*)?$/);
if (cronItem && (method === "PATCH" || method === "DELETE")) {
const index = cronJobs.findIndex((entry) => entry.id === cronItem[1]);
if (index >= 0 && method === "PATCH") {
const { id: _ignoredId, ...patch } = parsedBody || {};
cronJobs[index] = {
...cronJobs[index],
...patch,
};
}
if (index >= 0 && method === "DELETE") {
cronJobs.splice(index, 1);
}
json(res, 200, {
success: true,
data: { id: cronItem[1], deleted: method === "DELETE" },
@@ -34,17 +103,19 @@ export function handleCron(ctx) {
method === "GET" &&
/^\/settings\/webhooks-triggers\/?(\?.*)?$/.test(url)
) {
json(res, 200, { success: true, data: [] });
json(res, 200, { success: true, data: webhookTriggers });
return true;
}
if (method === "POST" && /^\/settings\/webhooks-triggers\/?$/.test(url)) {
const created = {
...(parsedBody || {}),
id: createMockId("trg"),
createdAt: new Date().toISOString(),
};
webhookTriggers.unshift(created);
json(res, 200, {
success: true,
data: {
id: "trg_mock_" + Date.now(),
...(parsedBody || {}),
createdAt: new Date().toISOString(),
},
data: created,
});
return true;
}
@@ -52,6 +123,17 @@ export function handleCron(ctx) {
/^\/settings\/webhooks-triggers\/([^/?]+)\/?(\?.*)?$/,
);
if (trgItem && (method === "PATCH" || method === "DELETE")) {
const index = webhookTriggers.findIndex((entry) => entry.id === trgItem[1]);
if (index >= 0 && method === "PATCH") {
const { id: _ignoredId, ...patch } = parsedBody || {};
webhookTriggers[index] = {
...webhookTriggers[index],
...patch,
};
}
if (index >= 0 && method === "DELETE") {
webhookTriggers.splice(index, 1);
}
json(res, 200, {
success: true,
data: { id: trgItem[1], deleted: method === "DELETE" },
+13 -4
View File
@@ -1,5 +1,6 @@
import { json } from "../http.mjs";
import { behavior, parseBehaviorJson, setMockBehavior } from "../state.mjs";
import { listMockLlmModels } from "./llm/shared.mjs";
export function handleIntegrations(ctx) {
const { method, url, parsedBody, res } = ctx;
@@ -137,7 +138,7 @@ export function handleIntegrations(ctx) {
// ── OpenAI proxy ───────────────────────────────────────────
if (method === "GET" && /^\/openai\/v1\/models\/?(\?.*)?$/.test(url)) {
json(res, 200, { data: [{ id: "e2e-mock-model", object: "model" }] });
json(res, 200, { data: listMockLlmModels() });
return true;
}
@@ -287,12 +288,16 @@ export function handleIntegrations(ctx) {
messages: [
{
id: "e2e-gmail-message-1",
snippet: "Welcome to OpenHuman. No profile link is required for this run.",
snippet:
"Welcome to OpenHuman. No profile link is required for this run.",
},
],
}
: { ok: true };
json(res, 200, { success: true, data: { successful: true, data, error: null } });
json(res, 200, {
success: true,
data: { successful: true, data, error: null },
});
return true;
}
@@ -308,7 +313,11 @@ export function handleIntegrations(ctx) {
} else {
json(res, 200, {
success: true,
data: { id: runId, status: "SUCCEEDED", finishedAt: new Date().toISOString() },
data: {
id: runId,
status: "SUCCEEDED",
finishedAt: new Date().toISOString(),
},
});
}
return true;
+134 -50
View File
@@ -1,11 +1,13 @@
import { json, setCors } from "../http.mjs";
import { behavior, parseBehaviorJson, setMockBehavior } from "../state.mjs";
function headerValue(headers, name) {
const raw = headers?.[name];
if (Array.isArray(raw)) return raw.join(", ");
return typeof raw === "string" ? raw : "";
}
import {
behavior,
getMockLlmThread,
parseBehaviorJson,
recordMockLlmTurn,
setMockBehavior,
} from "../state.mjs";
import { buildDynamicCompletion } from "./llm/dynamic.mjs";
import { headerValue, pickProbeText, resolveThreadKey } from "./llm/shared.mjs";
function requestRuleMatches(rule, ctx) {
if (!rule || typeof rule !== "object") return false;
@@ -221,7 +223,14 @@ function defaultStreamScript({ content, toolCalls }) {
return script;
}
function handleStreamingCompletion({ res, model, mockBehavior, parsedBody, rule }) {
function handleStreamingCompletion({
res,
model,
mockBehavior,
parsedBody,
rule,
dynamic,
}) {
writeSseHead(res);
// 1. Explicit streaming script overrides everything.
@@ -264,17 +273,24 @@ function handleStreamingCompletion({ res, model, mockBehavior, parsedBody, rule
if (!Array.isArray(script)) {
// 4. Default: stream a short greeting in a few chunks.
const fallback =
typeof rule?.content === "string" && rule.content.length > 0
? rule.content
: typeof mockBehavior.llmFallbackContent === "string" &&
mockBehavior.llmFallbackContent.length > 0
? mockBehavior.llmFallbackContent
: "Hello from e2e mock agent";
script = defaultStreamScript({
content: fallback,
toolCalls: Array.isArray(rule?.toolCalls) ? rule.toolCalls : undefined,
});
if (
Array.isArray(dynamic?.streamScript) &&
dynamic.streamScript.length > 0
) {
script = dynamic.streamScript;
} else {
const fallback =
typeof rule?.content === "string" && rule.content.length > 0
? rule.content
: typeof mockBehavior.llmFallbackContent === "string" &&
mockBehavior.llmFallbackContent.length > 0
? mockBehavior.llmFallbackContent
: "Hello from e2e mock agent";
script = defaultStreamScript({
content: fallback,
toolCalls: Array.isArray(rule?.toolCalls) ? rule.toolCalls : undefined,
});
}
}
const defaultDelayMs = Number.isFinite(
@@ -309,7 +325,9 @@ async function streamScriptToResponse({ res, model, script, defaultDelayMs }) {
let trailingUsage = null;
for (let i = 0; i < script.length; i += 1) {
const entry = script[i] ?? {};
const delay = Number.isFinite(entry.delayMs) ? entry.delayMs : defaultDelayMs;
const delay = Number.isFinite(entry.delayMs)
? entry.delayMs
: defaultDelayMs;
if (delay > 0) await sleep(delay);
if (entry.error) {
@@ -325,10 +343,7 @@ async function streamScriptToResponse({ res, model, script, defaultDelayMs }) {
}
if (typeof entry.text === "string") {
writeSseEvent(
res,
sseChunkEnvelope({ model, contentDelta: entry.text }),
);
writeSseEvent(res, sseChunkEnvelope({ model, contentDelta: entry.text }));
continue;
}
@@ -441,24 +456,6 @@ async function streamScriptToResponse({ res, model, script, defaultDelayMs }) {
* mental model applies on both sides of the FFI.
*/
function pickProbeText(parsedBody) {
if (!parsedBody || !Array.isArray(parsedBody.messages)) return "";
for (let i = parsedBody.messages.length - 1; i >= 0; i -= 1) {
const m = parsedBody.messages[i];
if (!m || typeof m !== "object") continue;
if (m.role === "user" || m.role === "tool") {
if (typeof m.content === "string") return m.content;
if (Array.isArray(m.content)) {
return m.content
.filter((c) => c && c.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join(" ");
}
}
}
return "";
}
function makeChoice({ content, toolCalls, callIdSeed }) {
const message = { role: "assistant", content: content ?? "" };
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
@@ -475,7 +472,11 @@ function makeChoice({ content, toolCalls, callIdSeed }) {
}));
if (!content) message.content = null;
}
return { index: 0, message, finish_reason: toolCalls?.length ? "tool_calls" : "stop" };
return {
index: 0,
message,
finish_reason: toolCalls?.length ? "tool_calls" : "stop",
};
}
function buildResponse({ model, content, toolCalls }) {
@@ -514,8 +515,15 @@ export function handleLlmCompletions(ctx) {
const model =
typeof parsedBody?.model === "string" ? parsedBody.model : "e2e-mock-model";
const requestRule = resolveRequestRule(ctx);
const threadKey = resolveThreadKey(ctx);
const thread = threadKey ? getMockLlmThread(threadKey) : null;
const dynamic = buildDynamicCompletion({ model, parsedBody, thread });
const requestText = pickProbeText(parsedBody);
if (requestRule?.error || (requestRule?.status && requestRule.status >= 400)) {
if (
requestRule?.error ||
(requestRule?.status && requestRule.status >= 400)
) {
if (
parsedBody?.stream === true &&
requestRule?.error &&
@@ -524,8 +532,7 @@ export function handleLlmCompletions(ctx) {
writeSseHead(res);
writeSseEvent(res, {
error: {
message:
requestRule?.error || "mock LLM streaming request rejected",
message: requestRule?.error || "mock LLM streaming request rejected",
type: requestRule?.type || "invalid_request_error",
code: requestRule?.code || null,
},
@@ -543,17 +550,47 @@ export function handleLlmCompletions(ctx) {
// attached, which is the production code path — non-streaming is
// only the OH-backend fallback. See compatible.rs `chat()`.
if (parsedBody?.stream === true) {
const recordTurn = (result) => {
if (!threadKey) return;
recordMockLlmTurn(threadKey, {
requestText,
responseText: result?.content ?? null,
toolCalls: result?.toolCalls ?? [],
model,
family: result?.family ?? dynamic.family,
codeLanguage: result?.codeLanguage ?? null,
});
};
if (
requestRule?.streamScript ||
requestRule?.content ||
requestRule?.toolCalls
) {
recordTurn({
content: requestRule?.content ?? "",
toolCalls: requestRule?.toolCalls ?? [],
family: dynamic.family,
});
} else {
recordTurn(dynamic);
}
return handleStreamingCompletion({
res,
model,
mockBehavior,
parsedBody,
rule: requestRule,
dynamic,
});
}
if (requestRule?.body && typeof requestRule.body === "object") {
json(res, Number.isInteger(requestRule.status) ? requestRule.status : 200, requestRule.body);
json(
res,
Number.isInteger(requestRule.status) ? requestRule.status : 200,
requestRule.body,
);
return true;
}
@@ -561,6 +598,15 @@ export function handleLlmCompletions(ctx) {
Array.isArray(requestRule?.toolCalls) ||
typeof requestRule?.content === "string"
) {
if (threadKey) {
recordMockLlmTurn(threadKey, {
requestText,
responseText: requestRule?.content ?? "",
toolCalls: requestRule?.toolCalls ?? [],
model,
family: dynamic.family,
});
}
json(
res,
Number.isInteger(requestRule?.status) ? requestRule.status : 200,
@@ -579,6 +625,15 @@ export function handleLlmCompletions(ctx) {
const next = forced.shift();
// Persist the shrunk queue back so subsequent requests advance.
setMockBehavior("llmForcedResponses", JSON.stringify(forced));
if (threadKey) {
recordMockLlmTurn(threadKey, {
requestText,
responseText: next.content ?? "",
toolCalls: next.toolCalls ?? [],
model,
family: dynamic.family,
});
}
json(res, 200, buildResponse({ model, ...next }));
return true;
}
@@ -590,6 +645,15 @@ export function handleLlmCompletions(ctx) {
for (const rule of rules) {
if (!rule || typeof rule.keyword !== "string") continue;
if (probe.includes(rule.keyword.toLowerCase())) {
if (threadKey) {
recordMockLlmTurn(threadKey, {
requestText,
responseText: rule.content ?? "",
toolCalls: rule.toolCalls ?? [],
model,
family: dynamic.family,
});
}
json(
res,
200,
@@ -606,10 +670,30 @@ export function handleLlmCompletions(ctx) {
// 3. Default fallback.
const fallback =
typeof mockBehavior.llmFallbackContent === "string" &&
dynamic.content ||
(typeof mockBehavior.llmFallbackContent === "string" &&
mockBehavior.llmFallbackContent.length > 0
? mockBehavior.llmFallbackContent
: "Hello from e2e mock agent";
json(res, 200, buildResponse({ model, content: fallback }));
: "Hello from e2e mock agent");
if (threadKey) {
recordMockLlmTurn(threadKey, {
requestText,
responseText: fallback,
toolCalls: dynamic.toolCalls ?? [],
toolResultText: null,
model,
family: dynamic.family,
codeLanguage: dynamic.codeLanguage ?? null,
});
}
json(
res,
200,
buildResponse({
model,
content: fallback,
toolCalls: dynamic.toolCalls ?? [],
}),
);
return true;
}
+327
View File
@@ -0,0 +1,327 @@
import { createMockId } from "../../state.mjs";
import {
collectMessagesByRole,
detectModelFamily,
latestRoleMessage,
} from "./shared.mjs";
function compactWhitespace(text) {
return String(text || "")
.replace(/\s+/g, " ")
.trim();
}
function sentenceParts(text) {
return compactWhitespace(text)
.split(/(?<=[.!?])\s+/)
.map((part) => part.trim())
.filter(Boolean);
}
function truncate(text, limit = 160) {
const compact = compactWhitespace(text);
if (compact.length <= limit) return compact;
return `${compact.slice(0, Math.max(0, limit - 1)).trimEnd()}`;
}
function inferLanguage(prompt, thread) {
const lower = String(prompt || "").toLowerCase();
if (/typescript|tsx|ts\b/.test(lower)) return "ts";
if (/javascript|node|react|js\b/.test(lower)) return "js";
if (/python|pytest/.test(lower)) return "python";
if (/rust|cargo/.test(lower)) return "rust";
if (/bash|shell|zsh|sh\b/.test(lower)) return "bash";
if (/json/.test(lower)) return "json";
return thread?.lastCodeLanguage || "ts";
}
function makeCodeSnippet(language, prompt, thread) {
const lower = String(prompt || "").toLowerCase();
if (language === "python") {
if (/async/.test(lower)) {
return [
"import asyncio",
"",
"async def run_task(name: str) -> str:",
" await asyncio.sleep(0)",
' return f\"done:{name}\"',
].join("\n");
}
if (/test/.test(lower)) {
return [
"def test_run_task_returns_done():",
' assert run_task_sync(\"build\") == \"done:build\"',
].join("\n");
}
return [
"def run_task_sync(name: str) -> str:",
' return f\"done:{name}\"',
].join("\n");
}
if (language === "rust") {
if (/async/.test(lower)) {
return [
"pub async fn run_task(name: &str) -> String {",
' format!("done:{name}")',
"}",
].join("\n");
}
return [
"pub fn run_task(name: &str) -> String {",
' format!("done:{name}")',
"}",
].join("\n");
}
if (language === "bash") {
return [
"#!/usr/bin/env bash",
"set -euo pipefail",
'name=\"${1:-build}\"',
'printf \"done:%s\\n\" \"$name\"',
].join("\n");
}
if (language === "json") {
return JSON.stringify(
{
task: "mock",
prompt: truncate(prompt, 48),
mode: /async/.test(lower) ? "async" : "sync",
},
null,
2,
);
}
const asyncKeyword = language === "ts" ? "async " : "";
const typeSuffix = language === "ts" ? ": Promise<string>" : "";
const awaitLine = /async/.test(lower) ? " await Promise.resolve();\n" : "";
const testBlock = /test/.test(lower)
? `\nexport function runTaskTest() {\n return runTask("build");\n}\n`
: "\n";
return [
`${asyncKeyword}function runTask(name${language === "ts" ? ": string" : ""})${typeSuffix} {`,
awaitLine ? awaitLine.trimEnd() : " return `done:${name}`;",
awaitLine ? " return `done:${name}`;" : "",
"}",
testBlock.trimEnd(),
]
.filter(Boolean)
.join("\n");
}
function summarizeText(source, style = "balanced") {
const sentences = sentenceParts(source);
if (sentences.length === 0) return "Nothing substantial to summarize yet.";
if (style === "brief") return truncate(sentences.slice(0, 1).join(" "), 120);
if (style === "bullets") {
return sentences
.slice(0, 3)
.map((sentence) => `- ${truncate(sentence, 90)}`)
.join("\n");
}
return truncate(sentences.slice(0, 3).join(" "), 240);
}
function buildReasoningSummary(prompt, toolText, thread) {
const basis = toolText || prompt || thread?.lastUserMessage || "the request";
return [
`Recommendation: focus on ${truncate(basis, 80)}.`,
thread?.turnCount > 0
? "This follows the previous turn and keeps the same working context."
: "This is the first pass, so the answer stays broad and reversible.",
"If you want, I can tighten this into an implementation plan or a direct patch.",
].join(" ");
}
function inferToolCalls(prompt, toolDefs = []) {
const lower = String(prompt || "").toLowerCase();
const declared = Array.isArray(toolDefs) ? toolDefs : [];
const picked = (name, args) => ({
id: createMockId("tool"),
name,
arguments: args,
});
if (/search|look up|find|research/.test(lower)) {
return [
picked(declared[0]?.function?.name || declared[0]?.name || "web_search", {
q: truncate(prompt, 120),
}),
];
}
if (/read file|open file|inspect file/.test(lower)) {
return [
picked(declared[0]?.function?.name || declared[0]?.name || "fs_read", {
path: "src/example.ts",
}),
];
}
if (/run|shell|command|git/.test(lower)) {
return [
picked(declared[0]?.function?.name || declared[0]?.name || "shell_exec", {
cmd: "echo mock-agentic-run",
}),
];
}
if (/fetch|download|http|url/.test(lower)) {
return [
picked(declared[0]?.function?.name || declared[0]?.name || "http_fetch", {
url: "https://example.com/mock",
}),
];
}
if (declared.length > 0) {
return [
picked(declared[0]?.function?.name || declared[0]?.name || "mock_tool", {
input: truncate(prompt, 120),
}),
];
}
return [];
}
function latestToolResult(parsedBody, thread) {
const toolMessage = latestRoleMessage(parsedBody, "tool");
return toolMessage?.normalizedContent || thread?.lastToolResult || "";
}
function followUpMode(prompt) {
const lower = String(prompt || "").toLowerCase();
return {
shorter: /shorter|brief|condense|tldr|tl;dr/.test(lower),
continue: /continue|go on|next/.test(lower),
expand: /expand|more detail|elaborate/.test(lower),
async: /async/.test(lower),
tests: /test|spec/.test(lower),
};
}
export function buildDynamicCompletion({ model, parsedBody, thread }) {
const family = detectModelFamily({ model, parsedBody });
const latestUser = latestRoleMessage(parsedBody, "user");
const prompt = latestUser?.normalizedContent || "";
const toolText = latestToolResult(parsedBody, thread);
const mode = followUpMode(prompt);
if (family === "summarization") {
const sourceCandidates = [
toolText,
thread?.lastAssistantContent,
...collectMessagesByRole(parsedBody, "assistant").map(
(item) => item.normalizedContent,
),
...collectMessagesByRole(parsedBody, "user")
.slice(0, -1)
.map((item) => item.normalizedContent),
].filter(Boolean);
const source = sourceCandidates.join(" ");
const content = summarizeText(
source ||
prompt ||
thread?.lastUserMessage ||
"No prior material was provided.",
mode.shorter
? "brief"
: /bullet|list/.test(prompt.toLowerCase())
? "bullets"
: "balanced",
);
return {
family,
content,
streamScript: [{ text: content }, { finish: "stop" }],
codeLanguage: null,
};
}
if (family === "coding") {
const language = inferLanguage(prompt, thread);
const code = makeCodeSnippet(language, prompt, thread);
const intro =
thread?.turnCount > 0
? `Updated ${language.toUpperCase()} version based on the previous turn:`
: `Generated ${language.toUpperCase()} starter:`;
const content = `${intro}\n\n\`\`\`${language}\n${code}\n\`\`\``;
return {
family,
content,
streamScript: [
{ text: intro },
{ text: `\n\n\`\`\`${language}\n${code}\n\`\`\`` },
{ finish: "stop" },
],
codeLanguage: language,
};
}
if (family === "agentic") {
const toolCalls = toolText ? [] : inferToolCalls(prompt, parsedBody?.tools);
const content = toolText
? `Tool result received. The relevant outcome is: ${truncate(
toolText,
mode.shorter ? 100 : 220,
)}${mode.expand ? " I can turn this into a fuller answer if needed." : ""}`
: "Planning tool work now.";
return {
family,
content,
toolCalls,
streamScript:
toolCalls.length > 0
? [
{ text: "Planning tool work now." },
...toolCalls.map((toolCall) => ({ toolCall })),
{ finish: "tool_calls" },
]
: [{ text: content }, { finish: "stop" }],
codeLanguage: null,
};
}
if (family === "reasoning") {
const content = buildReasoningSummary(prompt, toolText, thread);
return {
family,
content,
streamScript: [
{
thinking: `Assessing the request about ${truncate(prompt || toolText || "the current task", 72)}.`,
},
{
thinking:
thread?.turnCount > 0
? "Incorporating prior turn context."
: "Choosing a safe first-pass approach.",
},
{ text: content },
{
usage: { prompt_tokens: 18, completion_tokens: 24, total_tokens: 42 },
},
{ finish: "stop" },
],
codeLanguage: null,
};
}
const content =
mode.shorter && thread?.lastAssistantContent
? truncate(thread.lastAssistantContent, 120)
: thread?.turnCount > 0 && (mode.continue || mode.expand)
? `Continuing from the previous turn: ${truncate(
thread.lastAssistantContent || toolText || prompt || "ready",
mode.expand ? 220 : 140,
)}`
: "Hello from e2e mock agent";
return {
family,
content,
streamScript: [{ text: content }, { finish: "stop" }],
codeLanguage: null,
};
}
+153
View File
@@ -0,0 +1,153 @@
import { behavior, parseBehaviorJson } from "../../state.mjs";
const DEFAULT_MODELS = [
{ id: "e2e-mock-model", family: "chat", owned_by: "openhuman-mock" },
{
id: "openhuman-reasoning-mock",
family: "reasoning",
owned_by: "openhuman-mock",
},
{ id: "o3-mini-mock", family: "reasoning", owned_by: "openhuman-mock" },
{
id: "openhuman-agentic-mock",
family: "agentic",
owned_by: "openhuman-mock",
},
{ id: "gpt-4.1-agent-mock", family: "agentic", owned_by: "openhuman-mock" },
{ id: "openhuman-coder-mock", family: "coding", owned_by: "openhuman-mock" },
{ id: "gpt-5-codex-mock", family: "coding", owned_by: "openhuman-mock" },
{
id: "openhuman-summary-mock",
family: "summarization",
owned_by: "openhuman-mock",
},
{
id: "memory-summarizer-mock",
family: "summarization",
owned_by: "openhuman-mock",
},
];
export function listMockLlmModels() {
const configured = parseBehaviorJson("llmModelCatalog", null);
const catalog =
Array.isArray(configured) && configured.length > 0
? configured
: DEFAULT_MODELS;
return catalog.map((entry, index) => ({
id: String(entry.id || `mock-model-${index + 1}`),
object: "model",
created: entry.created || 1_710_000_000,
owned_by: entry.owned_by || entry.ownedBy || "openhuman-mock",
family:
entry.family ||
detectModelFamily({
model: String(entry.id || `mock-model-${index + 1}`),
}),
}));
}
export function headerValue(headers, name) {
const raw = headers?.[name];
if (Array.isArray(raw)) return raw.join(", ");
return typeof raw === "string" ? raw : "";
}
export function pickProbeText(parsedBody) {
if (!parsedBody || !Array.isArray(parsedBody.messages)) return "";
for (let i = parsedBody.messages.length - 1; i >= 0; i -= 1) {
const m = parsedBody.messages[i];
if (!m || typeof m !== "object") continue;
if (m.role === "user" || m.role === "tool") {
if (typeof m.content === "string") return m.content;
if (Array.isArray(m.content)) {
return m.content
.filter((c) => c && c.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join(" ");
}
}
}
return "";
}
export function normalizeMessageContent(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(item) => item && item.type === "text" && typeof item.text === "string",
)
.map((item) => item.text)
.join(" ");
}
export function collectMessagesByRole(parsedBody, role) {
if (!Array.isArray(parsedBody?.messages)) return [];
return parsedBody.messages
.filter((message) => message?.role === role)
.map((message) => ({
...message,
normalizedContent: normalizeMessageContent(message.content),
}));
}
export function latestRoleMessage(parsedBody, role) {
const matches = collectMessagesByRole(parsedBody, role);
return matches[matches.length - 1] || null;
}
export function resolveThreadKey(ctx) {
const { parsedBody, req } = ctx;
const headers = req?.headers || {};
const body = parsedBody || {};
return (
headerValue(headers, "x-mock-thread-id") ||
headerValue(headers, "x-thread-id") ||
body.mockThreadId ||
body.threadId ||
body.conversationId ||
body.sessionId ||
body.metadata?.thread_id ||
body.metadata?.conversation_id ||
body.metadata?.session_id ||
body.user ||
null
);
}
function overrideFamilyForModel(model) {
const overrides = parseBehaviorJson("llmModelFamilyOverrides", []);
if (!Array.isArray(overrides)) return null;
for (const entry of overrides) {
if (!entry || typeof entry.family !== "string") continue;
if (typeof entry.model === "string" && entry.model === model) {
return entry.family;
}
if (
typeof entry.match === "string" &&
model.includes(entry.match.toLowerCase())
) {
return entry.family;
}
}
return null;
}
export function detectModelFamily({ model = "", parsedBody } = {}) {
const lower = String(model || "").toLowerCase();
const override = overrideFamilyForModel(lower);
if (override) return override;
if (/codex|coder|code|devstral|program|repair/.test(lower)) return "coding";
if (/summary|summar|memory|brief|distill|extract/.test(lower)) {
return "summarization";
}
if (/agent|tool|operator|workflow|computer|action/.test(lower)) {
return "agentic";
}
if (/reason|thinking|o1|o3|o4|r1|deepseek/.test(lower)) return "reasoning";
if (Array.isArray(parsedBody?.tools) && parsedBody.tools.length > 0) {
return "agentic";
}
return behavior().llmDefaultFamily || "chat";
}
+71 -3
View File
@@ -20,12 +20,16 @@ import { handlePayments } from "./routes/payments.mjs";
import { handleUser } from "./routes/user.mjs";
import { handleVersion } from "./routes/version.mjs";
import { handleWebhooks } from "./routes/webhooks.mjs";
import { handleEnginePollingOpen, handleWebSocketUpgrade } from "./socket.mjs";
import { handleSocketRequest, handleWebSocketUpgrade } from "./socket.mjs";
import {
appendRequest,
behavior,
DEFAULT_PORT,
hashString,
MAX_PORT_RETRY_ATTEMPTS,
openSockets,
parseBehaviorJson,
sleep,
} from "./state.mjs";
let server = null;
@@ -84,8 +88,11 @@ async function handleRequest(req, res) {
if (handleAdmin(ctx)) return;
const maybeShortCircuit = await maybeApplyGlobalBehavior(ctx);
if (maybeShortCircuit) return;
if (url.startsWith("/socket.io/")) {
handleEnginePollingOpen(req, res);
handleSocketRequest(ctx);
return;
}
@@ -101,6 +108,65 @@ async function handleRequest(req, res) {
});
}
function requestHash(ctx) {
return hashString(`${ctx.method}:${ctx.url}:${ctx.body || ""}`);
}
function ruleMatches(rule, ctx) {
if (!rule || typeof rule !== "object") return false;
if (rule.method && String(rule.method).toUpperCase() !== ctx.method)
return false;
if (typeof rule.path === "string" && rule.path !== ctx.url) return false;
if (typeof rule.pathRegex === "string") {
try {
const regex = new RegExp(rule.pathRegex);
if (!regex.test(ctx.url)) return false;
} catch {
return false;
}
}
if (typeof rule.contains === "string" && !ctx.url.includes(rule.contains)) {
return false;
}
return true;
}
async function maybeApplyGlobalBehavior(ctx) {
const mockBehavior = behavior();
const baseDelay = Number(mockBehavior.globalDelayMs || 0);
const jitterMax = Number(mockBehavior.globalJitterMs || 0);
const jitter =
Number.isFinite(jitterMax) && jitterMax > 0
? requestHash(ctx) % Math.min(jitterMax, 5000)
: 0;
const totalDelay = Math.max(0, baseDelay) + jitter;
if (totalDelay > 0) {
await sleep(Math.min(totalDelay, 30_000));
}
const rules = parseBehaviorJson("httpFaultRules", []);
if (!Array.isArray(rules)) return false;
for (const rule of rules) {
if (!ruleMatches(rule, ctx)) continue;
const status = Number(rule.status || 500);
const body =
rule.body && typeof rule.body === "object"
? rule.body
: {
success: false,
error: rule.error || "Injected mock fault",
};
console.warn(
`[MockServer] Injected fault ${ctx.method} ${ctx.url} -> ${status}`,
);
json(ctx.res, status, body);
return true;
}
return false;
}
export function getMockServerPort() {
const address = server?.address();
return typeof address === "object" && address ? address.port : null;
@@ -117,7 +183,9 @@ function createServerInstance() {
openSockets.add(socket);
socket.on("close", () => openSockets.delete(socket));
});
nextServer.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket));
nextServer.on("upgrade", (req, socket) =>
handleWebSocketUpgrade(req, socket),
);
return nextServer;
}
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearSocketEventLog,
disconnectMockSockets,
listSocketSessions,
resetMockBehavior,
setMockBehaviors,
startMockServer,
stopMockServer,
} from "./index.mjs";
import { createSocket, onceSocket } from "./test-helpers/socket-client.mjs";
test.beforeEach(async () => {
await stopMockServer();
resetMockBehavior();
clearSocketEventLog();
});
test.afterEach(async () => {
disconnectMockSockets();
await stopMockServer();
});
test("rejects connections that omit a required token", async () => {
const started = await startMockServer(18575, { retryIfInUse: true });
const baseUrl = `http://127.0.0.1:${started.port}`;
setMockBehaviors({ socketAuthMode: "required" }, "replace");
const rejectedSocket = createSocket(baseUrl, {
auth: {},
transports: ["polling"],
upgrade: false,
});
try {
const error = await onceSocket(rejectedSocket, "connect_error");
assert.match(String(error?.message || error), /No token provided/);
assert.equal(listSocketSessions().length, 0);
} finally {
rejectedSocket.disconnect();
}
});
+6 -142
View File
@@ -1,142 +1,6 @@
import crypto from "node:crypto";
import { setCors } from "./http.mjs";
export function handleEnginePollingOpen(req, res) {
if (!req.url?.startsWith("/socket.io/")) return false;
const eioOpen = JSON.stringify({
sid: "mock-sid-" + Date.now(),
upgrades: ["websocket"],
pingInterval: 25000,
pingTimeout: 20000,
});
const packet = `${eioOpen.length + 1}:0${eioOpen}`;
setCors(res);
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(packet);
return true;
}
function handleSocketIOMessage(socket, text, sid) {
if (text === "2") {
sendWsText(socket, "3");
return;
}
if (text.startsWith("40")) {
sendWsText(socket, `40{"sid":"${sid}"}`);
}
}
function sendWsText(socket, text) {
sendWsFrame(socket, 0x01, Buffer.from(text, "utf-8"));
}
function sendWsFrame(socket, opcode, payload) {
if (socket.destroyed) return;
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.alloc(2);
header[0] = 0x80 | opcode;
header[1] = len;
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
try {
socket.write(header);
socket.write(payload);
} catch {
// noop
}
}
export function handleWebSocketUpgrade(req, socket) {
if (!req.url?.startsWith("/socket.io/")) {
socket.destroy();
return;
}
const key = req.headers["sec-websocket-key"];
if (!key) {
socket.destroy();
return;
}
const acceptKey = crypto
.createHash("sha1")
.update(key + "258EAFA5-E914-47DA-95CA-5AB5DC085B11")
.digest("base64");
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
"\r\n",
);
const mockSid = "mock-ws-" + Date.now();
const eioOpen = JSON.stringify({
sid: mockSid,
upgrades: [],
pingInterval: 25000,
pingTimeout: 60000,
maxPayload: 1000000,
});
sendWsText(socket, `0${eioOpen}`);
let buffer = Buffer.alloc(0);
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 2) {
const firstByte = buffer[0];
const opcode = firstByte & 0x0f;
const secondByte = buffer[1];
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7f;
let offset = 2;
if (payloadLen === 126) {
if (buffer.length < 4) return;
payloadLen = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLen === 127) {
if (buffer.length < 10) return;
payloadLen = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
const maskSize = masked ? 4 : 0;
const totalLen = offset + maskSize + payloadLen;
if (buffer.length < totalLen) return;
let payload = buffer.subarray(offset + maskSize, totalLen);
if (masked) {
const mask = buffer.subarray(offset, offset + 4);
payload = Buffer.from(payload);
for (let i = 0; i < payload.length; i += 1) {
payload[i] ^= mask[i % 4];
}
}
buffer = buffer.subarray(totalLen);
if (opcode === 0x08) {
socket.end();
return;
}
if (opcode === 0x09) {
sendWsFrame(socket, 0x0a, payload);
continue;
}
if (opcode === 0x01) {
handleSocketIOMessage(socket, payload.toString("utf-8"), mockSid);
}
}
});
socket.on("error", () => {});
socket.on("close", () => {});
}
export {
disconnectMockSockets,
emitMockSocketEvent,
handleSocketRequest,
handleWebSocketUpgrade,
} from "./socket/index.mjs";
+128
View File
@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import test from "node:test";
import {
clearSocketEventLog,
disconnectMockSockets,
emitMockSocketEvent,
listSocketSessions,
resetMockBehavior,
startMockServer,
stopMockServer,
} from "./index.mjs";
import { handleWebSocketUpgrade } from "./socket.mjs";
import { getSocketSession, registerSocketSession } from "./state.mjs";
import { createSocket, onceSocket } from "./test-helpers/socket-client.mjs";
class FakeWebSocket extends EventEmitter {
constructor() {
super();
this.destroyed = false;
this.writes = [];
}
write(chunk) {
this.writes.push(String(chunk));
}
end() {
this.destroyed = true;
}
destroy() {
this.destroyed = true;
}
}
test.beforeEach(async () => {
await stopMockServer();
resetMockBehavior();
clearSocketEventLog();
});
test.afterEach(async () => {
disconnectMockSockets();
await stopMockServer();
});
test("accepts a real socket.io client and delivers server-pushed events", async () => {
const started = await startMockServer(18573, { retryIfInUse: true });
const baseUrl = `http://127.0.0.1:${started.port}`;
const socket = createSocket(baseUrl, {
auth: { token: "mock-jwt-token" },
transports: ["polling", "websocket"],
});
try {
const readyPayload = await onceSocket(socket, "ready");
assert.equal(typeof readyPayload.sid, "string");
assert.equal(readyPayload.userId, "mock-user");
assert.equal(listSocketSessions().length, 1);
const donePromise = onceSocket(socket, "chat_done");
const delivered = emitMockSocketEvent({
event: "chat_done",
data: {
thread_id: "thread-1",
request_id: "request-1",
full_response: "mock transport works",
rounds_used: 1,
total_input_tokens: 12,
total_output_tokens: 4,
},
});
assert.equal(delivered, 1);
const donePayload = await donePromise;
assert.equal(donePayload.full_response, "mock transport works");
assert.equal(donePayload.thread_id, "thread-1");
} finally {
socket.disconnect();
}
});
test("supports polling-only clients", async () => {
const started = await startMockServer(18574, { retryIfInUse: true });
const baseUrl = `http://127.0.0.1:${started.port}`;
const pollingSocket = createSocket(baseUrl, {
auth: { token: "polling-only" },
transports: ["polling"],
upgrade: false,
});
try {
const readyPayload = await onceSocket(pollingSocket, "ready");
assert.equal(readyPayload.userId, "mock-user");
} finally {
pollingSocket.disconnect();
}
});
test("keeps polling session alive when websocket probe closes before upgrade", () => {
const session = registerSocketSession({
sid: "probe-fallback-sid",
socketId: "probe-fallback-sid",
transport: "polling",
createdAt: new Date().toISOString(),
});
const socket = new FakeWebSocket();
handleWebSocketUpgrade(
{
url: `/socket.io/?transport=websocket&sid=${session.sid}`,
headers: { "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==" },
},
socket,
);
socket.emit("close");
const live = getSocketSession(session.sid);
assert.ok(live);
assert.equal(live.transport, "polling");
assert.equal(live.upgradedToWebSocket, false);
assert.equal(live.webSocket, null);
});
+426
View File
@@ -0,0 +1,426 @@
import { json, setCors } from "../http.mjs";
import {
appendSocketEvent,
attachWebSocketToSession,
behavior,
buildSocketReadyPayload,
createMockId,
drainSocketPackets,
getSocketSession,
listSocketSessions,
parseBehaviorJson,
queueSocketPacket,
registerSocketSession,
touchSocketSession,
dropSocketSession,
} from "../state.mjs";
import {
decodePollingPayload,
engineOpenPacket,
normalizeAuthPayload,
parseRequestUrl,
socketConnectErrorPacket,
socketConnectPacket,
socketEventPacket,
encodePollingPayload,
} from "./protocol.mjs";
import {
acceptWebSocket,
decodeWebSocketFrames,
sendWsText,
} from "./websocket.mjs";
function socketIoSid() {
return `mock-sio-${createMockId("sid")}`;
}
function authenticateSession(authPayload) {
const mockBehavior = behavior();
const socketAuthMode = mockBehavior.socketAuthMode || "required";
const token =
authPayload && typeof authPayload === "object"
? authPayload.token
: undefined;
if (socketAuthMode !== "disabled" && !token) {
return { ok: false, message: "No token provided" };
}
if (
mockBehavior.socketAuthMode === "reject" ||
mockBehavior.socketReject === "true" ||
String(token || "").includes("invalid")
) {
return {
ok: false,
message: mockBehavior.socketRejectMessage || "Authentication failed",
};
}
return {
ok: true,
token: token || null,
userId: mockBehavior.socketUserId || mockBehavior.userId || "mock-user",
};
}
function logSocketCheckpoint(kind, detail = {}) {
appendSocketEvent({ direction: "system", kind, ...detail });
}
function writePollingResponse(res, packets) {
setCors(res);
res.writeHead(200, { "Content-Type": "text/plain; charset=UTF-8" });
res.end(encodePollingPayload(packets));
}
function writePollingOk(res) {
setCors(res);
res.writeHead(200, { "Content-Type": "text/plain; charset=UTF-8" });
res.end("ok");
}
function sendSocketPacket(session, packet) {
const target = getSocketSession(session.sid);
if (!target) return false;
target.lastSeenAt = new Date().toISOString();
if (
target.webSocket &&
!target.webSocket.destroyed &&
target.upgradedToWebSocket === true
) {
sendWsText(target.webSocket, packet);
return true;
}
return queueSocketPacket(target.sid, packet);
}
function cleanupRejectedSession(session) {
const live = getSocketSession(session.sid);
if (!live) return;
if (live.webSocket && !live.webSocket.destroyed) {
try {
live.webSocket.end?.();
live.webSocket.destroy?.();
} catch {
// noop
}
dropSocketSession(live.sid);
return;
}
touchSocketSession(live.sid, { disconnectAfterDrain: true });
}
function scheduleMockSocketActions(session, actions = []) {
for (const action of actions) {
const delayMs = Math.max(0, Number(action?.delayMs || 0));
setTimeout(() => {
if (action?.disconnect === true) {
disconnectMockSockets({ targetSid: session.sid });
return;
}
if (typeof action?.event === "string" && action.event) {
emitMockSocketEvent({
event: action.event,
data:
action.data === "__ready__"
? buildSocketReadyPayload(session)
: (action.data ?? null),
targetSid: session.sid,
});
}
}, delayMs);
}
}
function onSocketConnected(session) {
touchSocketSession(session.sid, { connected: true });
sendSocketPacket(session, socketConnectPacket(session));
sendSocketPacket(
session,
socketEventPacket("ready", buildSocketReadyPayload(session)),
);
logSocketCheckpoint("connected", {
sid: session.sid,
socketId: session.socketId,
userId: session.userId,
transport: session.transport,
});
const connectScript = parseBehaviorJson("socketConnectScript", []);
if (Array.isArray(connectScript) && connectScript.length > 0) {
scheduleMockSocketActions(session, connectScript);
}
}
function handleClientSocketEvent(session, event, data) {
appendSocketEvent({
direction: "inbound",
kind: "event",
sid: session.sid,
socketId: session.socketId,
userId: session.userId,
event,
data,
});
if (event === "webhook:response" && data?.correlationId) {
sendSocketPacket(
session,
socketEventPacket(`webhook:response:${data.correlationId}`, data),
);
}
const scripts = parseBehaviorJson("socketClientEventScripts", {});
const actions = Array.isArray(scripts?.[event]) ? scripts[event] : [];
if (actions.length > 0) {
scheduleMockSocketActions(session, actions);
}
}
function handleSocketPacket(session, packet) {
touchSocketSession(session.sid);
if (packet === "2") {
sendSocketPacket(session, "3");
return;
}
if (packet === "2probe") {
sendSocketPacket(session, "3probe");
return;
}
if (packet === "5") {
touchSocketSession(session.sid, {
upgradedToWebSocket: true,
transport: "websocket",
});
logSocketCheckpoint("upgrade_complete", { sid: session.sid });
const pending = drainSocketPackets(session.sid);
const live = getSocketSession(session.sid);
if (live?.webSocket && pending.length > 0) {
for (const queued of pending) {
sendWsText(live.webSocket, queued);
}
}
return;
}
if (packet.startsWith("40")) {
const auth = normalizeAuthPayload(packet);
const result = authenticateSession(auth);
if (!result.ok) {
sendSocketPacket(session, socketConnectErrorPacket(result.message));
appendSocketEvent({
direction: "outbound",
kind: "connect_error",
sid: session.sid,
message: result.message,
});
cleanupRejectedSession(session);
return;
}
touchSocketSession(session.sid, {
token: result.token,
userId: result.userId,
});
onSocketConnected(getSocketSession(session.sid));
return;
}
if (packet.startsWith("42")) {
try {
const payload = JSON.parse(packet.slice(2));
const [event, data] = Array.isArray(payload) ? payload : [];
if (typeof event === "string" && event) {
handleClientSocketEvent(session, event, data);
}
} catch {
appendSocketEvent({
direction: "system",
kind: "parse_error",
sid: session.sid,
packet,
});
}
}
}
function createSession(transport) {
const sid = socketIoSid();
const session = registerSocketSession({
sid,
socketId: sid,
transport,
createdAt: new Date().toISOString(),
});
logSocketCheckpoint("session_created", { sid, transport });
return session;
}
function lookupSessionFromUrl(urlObj) {
const sid = urlObj.searchParams.get("sid");
if (!sid) return null;
return getSocketSession(sid);
}
function matchSession(session, filters = {}) {
if (filters.targetSid && session.sid !== filters.targetSid) return false;
if (filters.excludeSid && session.sid === filters.excludeSid) return false;
if (filters.targetUserId && session.userId !== filters.targetUserId) {
return false;
}
return true;
}
export function handleSocketRequest(ctx) {
const { method, url, body, res } = ctx;
if (!url?.startsWith("/socket.io/")) return false;
const urlObj = parseRequestUrl(url);
const transport = urlObj.searchParams.get("transport");
if (transport !== "polling") {
json(res, 400, {
success: false,
error: "Mock socket only handles polling HTTP",
});
return true;
}
if (method === "GET") {
const existing = lookupSessionFromUrl(urlObj);
if (!existing) {
const session = createSession("polling");
writePollingResponse(res, [engineOpenPacket(session.sid)]);
return true;
}
const packets = drainSocketPackets(existing.sid);
if (existing.disconnectAfterDrain === true) {
dropSocketSession(existing.sid);
}
writePollingResponse(res, packets.length > 0 ? packets : ["6"]);
return true;
}
if (method === "POST") {
const session = lookupSessionFromUrl(urlObj);
if (!session) {
json(res, 400, { success: false, error: "Unknown socket session" });
return true;
}
const packets = decodePollingPayload(body);
for (const packet of packets) {
handleSocketPacket(session, packet);
}
writePollingOk(res);
return true;
}
json(res, 405, { success: false, error: "Method not allowed" });
return true;
}
export function handleWebSocketUpgrade(req, socket) {
if (!req.url?.startsWith("/socket.io/")) {
socket.destroy();
return;
}
if (!acceptWebSocket(req, socket)) return;
const urlObj = parseRequestUrl(req.url);
const requestedSid = urlObj.searchParams.get("sid");
let session = requestedSid ? getSocketSession(requestedSid) : null;
if (!session) {
session = createSession("websocket");
attachWebSocketToSession(session.sid, socket, {
transport: "websocket",
upgraded: true,
});
sendWsText(socket, engineOpenPacket(session.sid, []));
} else {
attachWebSocketToSession(session.sid, socket, { upgraded: false });
}
logSocketCheckpoint("websocket_attached", { sid: session.sid, requestedSid });
decodeWebSocketFrames(socket, (packet) =>
handleSocketPacket(session, packet),
);
socket.on("close", () => {
const live = getSocketSession(session.sid);
if (live?.upgradedToWebSocket === true) {
dropSocketSession(session.sid);
} else if (live) {
touchSocketSession(session.sid, {
webSocket: null,
transport: "polling",
});
}
logSocketCheckpoint("websocket_closed", { sid: session.sid });
});
socket.on("error", () => {});
}
export function emitMockSocketEvent({
event,
data,
targetSid,
targetUserId,
excludeSid,
delayMs = 0,
}) {
if (typeof event !== "string" || !event) return 0;
const matchingSessions = listSocketSessions().filter((session) =>
matchSession(session, { targetSid, targetUserId, excludeSid }),
);
const deliver = () => {
for (const info of matchingSessions) {
const session = getSocketSession(info.sid);
if (!session) continue;
sendSocketPacket(session, socketEventPacket(event, data));
appendSocketEvent({
direction: "outbound",
kind: "event",
sid: session.sid,
socketId: session.socketId,
userId: session.userId,
event,
data,
});
}
};
const normalizedDelay = Math.max(0, Number(delayMs || 0));
if (normalizedDelay > 0) {
setTimeout(deliver, normalizedDelay);
} else {
deliver();
}
return matchingSessions.length;
}
export function disconnectMockSockets({ targetSid, targetUserId } = {}) {
let disconnected = 0;
for (const sessionInfo of listSocketSessions()) {
if (!matchSession(sessionInfo, { targetSid, targetUserId })) continue;
const session = getSocketSession(sessionInfo.sid);
if (!session) continue;
try {
session.webSocket?.end?.();
session.webSocket?.destroy?.();
} catch {
// noop
}
dropSocketSession(session.sid);
disconnected += 1;
}
return disconnected;
}
+6
View File
@@ -0,0 +1,6 @@
export {
disconnectMockSockets,
emitMockSocketEvent,
handleSocketRequest,
handleWebSocketUpgrade,
} from "./core.mjs";
+52
View File
@@ -0,0 +1,52 @@
export const EIO_PING_INTERVAL = 25_000;
export const EIO_PING_TIMEOUT = 20_000;
export const EIO_MAX_PAYLOAD = 1_000_000;
export const POLLING_SEPARATOR = "\x1e";
export function parseRequestUrl(rawUrl) {
return new URL(rawUrl || "/socket.io/", "http://127.0.0.1");
}
export function engineOpenPacket(sid, upgrades = ["websocket"]) {
return `0${JSON.stringify({
sid,
upgrades,
pingInterval: EIO_PING_INTERVAL,
pingTimeout: EIO_PING_TIMEOUT,
maxPayload: EIO_MAX_PAYLOAD,
})}`;
}
export function socketConnectPacket(session) {
return `40${JSON.stringify({ sid: session.socketId })}`;
}
export function socketConnectErrorPacket(message) {
return `44${JSON.stringify({ message })}`;
}
export function socketEventPacket(event, data) {
const payload = data === undefined ? [event] : [event, data];
return `42${JSON.stringify(payload)}`;
}
export function encodePollingPayload(packets) {
return packets.join(POLLING_SEPARATOR);
}
export function decodePollingPayload(rawBody) {
const body = String(rawBody || "");
if (!body) return [];
return body.split(POLLING_SEPARATOR).filter(Boolean);
}
export function normalizeAuthPayload(packet) {
if (!packet.startsWith("40")) return null;
const payload = packet.slice(2).trim();
if (!payload) return {};
try {
return JSON.parse(payload);
} catch {
return {};
}
}
+109
View File
@@ -0,0 +1,109 @@
import crypto from "node:crypto";
export function sendWsFrame(socket, opcode, payload) {
if (!socket || socket.destroyed) return;
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.alloc(2);
header[0] = 0x80 | opcode;
header[1] = len;
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
try {
socket.write(header);
socket.write(payload);
} catch {
// noop
}
}
export function sendWsText(socket, text) {
sendWsFrame(socket, 0x01, Buffer.from(text, "utf-8"));
}
export function acceptWebSocket(req, socket) {
const key = req.headers["sec-websocket-key"];
if (!key) {
socket.destroy();
return false;
}
const acceptKey = crypto
.createHash("sha1")
.update(key + "258EAFA5-E914-47DA-95CA-5AB5DC085B11")
.digest("base64");
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
"\r\n",
);
return true;
}
export function decodeWebSocketFrames(socket, onText) {
let buffer = Buffer.alloc(0);
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 2) {
const firstByte = buffer[0];
const opcode = firstByte & 0x0f;
const secondByte = buffer[1];
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7f;
let offset = 2;
if (payloadLen === 126) {
if (buffer.length < 4) return;
payloadLen = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLen === 127) {
if (buffer.length < 10) return;
payloadLen = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
const maskSize = masked ? 4 : 0;
const totalLen = offset + maskSize + payloadLen;
if (buffer.length < totalLen) return;
let payload = buffer.subarray(offset + maskSize, totalLen);
if (masked) {
const mask = buffer.subarray(offset, offset + 4);
payload = Buffer.from(payload);
for (let i = 0; i < payload.length; i += 1) {
payload[i] ^= mask[i % 4];
}
}
buffer = buffer.subarray(totalLen);
if (opcode === 0x08) {
socket.end();
return;
}
if (opcode === 0x09) {
sendWsFrame(socket, 0x0a, payload);
continue;
}
if (opcode === 0x01) {
onText(payload.toString("utf-8"));
}
}
});
}
+347 -11
View File
@@ -8,6 +8,15 @@ export const MAX_MOCK_DELAY_MS = 30_000;
let requestLog = [];
let mockBehavior = {};
let mockTunnels = [];
let mockConversations = [];
let mockMessages = [];
let mockCronJobs = [];
let mockWebhookTriggers = [];
let socketEventLog = [];
let mockLlmThreads = new Map();
let nextSequence = 1;
const socketSessions = new Map();
export const openSockets = new Set();
@@ -23,6 +32,12 @@ export function appendRequest(entry) {
requestLog.push(entry);
}
export function nextMockSequence() {
const value = nextSequence;
nextSequence += 1;
return value;
}
export function getMockBehavior() {
return { ...mockBehavior };
}
@@ -48,6 +63,53 @@ export function behavior() {
return mockBehavior;
}
export function parseInteger(value, fallback = 0) {
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : fallback;
}
export function hashString(input) {
const text = String(input ?? "");
let hash = 2166136261;
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
export function behaviorSeed() {
return String(mockBehavior.seed || "openhuman-mock");
}
export function fuzzyNumber(label, min, max) {
const lower = Math.min(min, max);
const upper = Math.max(min, max);
if (lower === upper) return lower;
const hash = hashString(`${behaviorSeed()}:${label}`);
return lower + (hash % (upper - lower + 1));
}
export function fuzzyPick(label, values, fallback = null) {
if (!Array.isArray(values) || values.length === 0) return fallback;
const index = fuzzyNumber(label, 0, values.length - 1);
return values[index];
}
export function fuzzyTimestamp(label, spreadMs = 14 * 24 * 60 * 60 * 1000) {
const now = Date.now();
const offset = fuzzyNumber(label, 0, spreadMs);
return new Date(now - offset).toISOString();
}
export function createMockId(prefix = "mock") {
return `${prefix}_${nextMockSequence()}_${hashString(
`${prefix}:${behaviorSeed()}:${Date.now()}:${Math.random()}`,
)
.toString(16)
.slice(0, 8)}`;
}
export function parseBehaviorJson(key, fallback) {
const raw = mockBehavior[key];
if (!raw) return JSON.parse(JSON.stringify(fallback));
@@ -80,6 +142,250 @@ export function resetMockTunnels() {
mockTunnels = [];
}
export function getMockConversations() {
return mockConversations;
}
export function setMockConversations(next) {
mockConversations = Array.isArray(next) ? next : [];
}
export function resetMockConversations() {
mockConversations = [];
}
export function getMockMessages() {
return mockMessages;
}
export function setMockMessages(next) {
mockMessages = Array.isArray(next) ? next : [];
}
export function resetMockMessages() {
mockMessages = [];
}
export function getMockCronJobs() {
return mockCronJobs;
}
export function setMockCronJobs(next) {
mockCronJobs = Array.isArray(next) ? next : [];
}
export function resetMockCronJobs() {
mockCronJobs = [];
}
export function getMockWebhookTriggers() {
return mockWebhookTriggers;
}
export function setMockWebhookTriggers(next) {
mockWebhookTriggers = Array.isArray(next) ? next : [];
}
export function resetMockWebhookTriggers() {
mockWebhookTriggers = [];
}
export function listMockLlmThreads() {
return Array.from(mockLlmThreads.values()).map((thread) => ({
key: thread.key,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
turnCount: thread.turnCount || 0,
lastModel: thread.lastModel || null,
lastFamily: thread.lastFamily || null,
lastUserMessage: thread.lastUserMessage || null,
lastAssistantContent: thread.lastAssistantContent || null,
lastToolCallCount: Array.isArray(thread.lastToolCalls)
? thread.lastToolCalls.length
: 0,
}));
}
export function getMockLlmThread(key) {
return mockLlmThreads.get(String(key)) || null;
}
export function touchMockLlmThread(key, patch = {}) {
if (!key) return null;
const threadKey = String(key);
const current = getMockLlmThread(threadKey) || {
key: threadKey,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
turnCount: 0,
history: [],
lastModel: null,
lastFamily: null,
lastUserMessage: null,
lastAssistantContent: null,
lastToolCalls: [],
lastToolResult: null,
lastCodeLanguage: null,
};
const next = {
...current,
...patch,
updatedAt: new Date().toISOString(),
};
mockLlmThreads.set(threadKey, next);
return next;
}
export function recordMockLlmTurn(key, turn = {}) {
if (!key) return null;
const thread = touchMockLlmThread(key);
const history = Array.isArray(thread.history) ? [...thread.history] : [];
history.push({
timestamp: new Date().toISOString(),
requestText: turn.requestText || null,
responseText: turn.responseText || null,
toolCalls: Array.isArray(turn.toolCalls) ? turn.toolCalls : [],
toolResultText: turn.toolResultText || null,
model: turn.model || null,
family: turn.family || null,
});
const next = touchMockLlmThread(key, {
history: history.slice(-12),
turnCount: (thread.turnCount || 0) + 1,
lastModel: turn.model || thread.lastModel || null,
lastFamily: turn.family || thread.lastFamily || null,
lastUserMessage: turn.requestText || thread.lastUserMessage || null,
lastAssistantContent:
turn.responseText ?? thread.lastAssistantContent ?? null,
lastToolCalls: Array.isArray(turn.toolCalls)
? turn.toolCalls
: (thread.lastToolCalls ?? []),
lastToolResult: turn.toolResultText ?? thread.lastToolResult ?? null,
lastCodeLanguage: turn.codeLanguage ?? thread.lastCodeLanguage ?? null,
});
return next;
}
export function resetMockLlmThreads() {
mockLlmThreads = new Map();
}
export function listSocketSessions() {
return Array.from(socketSessions.values()).map((session) => ({
sid: session.sid,
socketId: session.socketId,
connected: session.connected === true,
upgradedToWebSocket: session.upgradedToWebSocket === true,
transport: session.transport || "polling",
userId: session.userId || null,
tokenPresent: Boolean(session.token),
createdAt: session.createdAt,
lastSeenAt: session.lastSeenAt,
outgoingQueueLength: Array.isArray(session.pendingPackets)
? session.pendingPackets.length
: 0,
}));
}
export function getSocketSession(sid) {
return socketSessions.get(String(sid)) || null;
}
export function registerSocketSession(session) {
const next = {
sid: session.sid,
socketId: session.socketId || session.sid,
connected: false,
upgradedToWebSocket: false,
transport: session.transport || "polling",
token: session.token || null,
userId: session.userId || null,
createdAt: session.createdAt || new Date().toISOString(),
lastSeenAt: new Date().toISOString(),
pendingPackets: [],
webSocket: null,
};
socketSessions.set(next.sid, next);
return next;
}
export function touchSocketSession(sid, patch = {}) {
const session = getSocketSession(sid);
if (!session) return null;
Object.assign(session, patch, { lastSeenAt: new Date().toISOString() });
return session;
}
export function attachWebSocketToSession(sid, webSocket, options = {}) {
return touchSocketSession(sid, {
webSocket,
...(options.transport ? { transport: options.transport } : {}),
...(typeof options.upgraded === "boolean"
? { upgradedToWebSocket: options.upgraded }
: {}),
});
}
export function dropSocketSession(sid) {
const session = getSocketSession(sid);
if (!session) return;
try {
session.webSocket?.destroy?.();
} catch {
// noop
}
socketSessions.delete(session.sid);
}
export function queueSocketPacket(sid, packet) {
const session = getSocketSession(sid);
if (!session) return false;
session.pendingPackets.push(String(packet));
session.lastSeenAt = new Date().toISOString();
return true;
}
export function drainSocketPackets(sid) {
const session = getSocketSession(sid);
if (!session) return [];
const packets = [...session.pendingPackets];
session.pendingPackets = [];
session.lastSeenAt = new Date().toISOString();
return packets;
}
export function appendSocketEvent(entry) {
socketEventLog.push({
id: createMockId("sockevt"),
timestamp: new Date().toISOString(),
...entry,
});
}
export function getSocketEventLog() {
return [...socketEventLog];
}
export function clearSocketEventLog() {
socketEventLog = [];
}
export function resetSocketSessions() {
for (const sid of socketSessions.keys()) {
dropSocketSession(sid);
}
socketSessions.clear();
clearSocketEventLog();
}
export function buildSocketReadyPayload(session) {
return {
sid: session.socketId,
userId: session.userId || "mock-user",
transport: session.transport,
};
}
export function createMockTunnel(payload = {}) {
const now = new Date().toISOString();
return {
@@ -94,14 +400,37 @@ export function createMockTunnel(payload = {}) {
}
export function getMockUser() {
const firstName =
mockBehavior.firstName ||
fuzzyPick(
"user:first",
["Test", "Casey", "Robin", "Jordan", "Taylor"],
"Test",
);
const lastName =
mockBehavior.lastName ||
fuzzyPick(
"user:last",
["User", "Walker", "Lane", "Rivera", "Stone"],
"User",
);
const username =
mockBehavior.username ||
`${String(firstName).toLowerCase()}${fuzzyNumber("user:username", 10, 99)}`;
return {
_id: "user-123",
telegramId: 12345678,
_id: mockBehavior.userId || "user-123",
telegramId: fuzzyNumber("user:telegramId", 10_000_000, 99_999_999),
hasAccess: true,
magicWord: "alpha",
firstName: "Test",
lastName: "User",
username: "testuser",
magicWord:
mockBehavior.magicWord ||
fuzzyPick(
"user:magicWord",
["alpha", "delta", "spruce", "harbor", "ember"],
"alpha",
),
firstName,
lastName,
username,
role: "user",
activeTeamId: "team-1",
referral: {},
@@ -130,11 +459,18 @@ export function getMockTeam() {
const plan = mockBehavior.plan || "FREE";
const isActive = mockBehavior.planActive === "true";
const expiry = mockBehavior.planExpiry || null;
const teamName =
mockBehavior.teamName ||
fuzzyPick(
"team:name",
["Personal", "Studio", "Field Ops", "Research"],
"Personal",
);
return {
team: {
_id: "team-1",
name: "Personal",
slug: "personal",
name: teamName,
slug: String(teamName).toLowerCase().replace(/\s+/g, "-"),
createdBy: "test-user-123",
isPersonal: true,
maxMembers: 1,
@@ -144,9 +480,9 @@ export function getMockTeam() {
planExpiry: expiry,
},
usage: {
dailyTokenLimit: 1000,
remainingTokens: 1000,
activeSessionCount: 0,
dailyTokenLimit: fuzzyNumber("team:dailyTokenLimit", 500, 5000),
remainingTokens: fuzzyNumber("team:remainingTokens", 100, 5000),
activeSessionCount: listSocketSessions().length,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -0,0 +1,49 @@
import { createRequire } from "node:module";
const requireFromApp = createRequire(
new URL("../../../app/package.json", import.meta.url),
);
export const { io: SocketClient } = requireFromApp("socket.io-client");
export function onceSocket(socket, event) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for socket event: ${event}`));
}, 5_000);
const onEvent = (...args) => {
cleanup();
resolve(args[0]);
};
const onError = (err) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
};
const cleanup = () => {
clearTimeout(timeout);
socket.off(event, onEvent);
if (event !== "connect_error") {
socket.off("connect_error", onError);
}
};
socket.on(event, onEvent);
if (event !== "connect_error") {
socket.on("connect_error", onError);
}
});
}
export function createSocket(baseUrl, options = {}) {
return SocketClient(baseUrl, {
path: "/socket.io/",
reconnection: false,
forceNew: true,
timeout: 3_000,
...options,
});
}