mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test(mock-api): add fuzzy socket-aware harness (#1963)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
disconnectMockSockets,
|
||||
emitMockSocketEvent,
|
||||
handleSocketRequest,
|
||||
handleWebSocketUpgrade,
|
||||
} from "./core.mjs";
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user