Files
openhuman/test/e2e/mock-server.ts
T
29f542a3d7 feat: add E2E tests for card payment processing flow (#124)
* feat: add E2E tests for auth access control and billing/subscription flows

Add comprehensive E2E test coverage for authentication, access control,
and billing/subscription lifecycle using Appium mac2 + WebDriverIO.

New test cases (11 total):
- 1.1 User registration via deep link
- 1.1.1 Duplicate account handling
- 1.2 Multi-device sessions
- 3.1.1 Default FREE plan allocation
- 3.2.1 Upgrade flow (Stripe purchase API + polling)
- 3.2.2 Downgrade flow verification
- 3.3.1 Active subscription display
- 3.3.2 Renewal date handling
- 3.3.3 Cancellation via Stripe portal
- 1.3 Logout via Settings
- 1.3.1 Revoked session auto-logout

Mock server additions:
- Dynamic team data with subscription controlled by mockBehavior
- /payments/stripe/currentPlan, purchasePlan, portal routes
- /payments/coinbase/charge route

Split e2e.sh into per-flow scripts (e2e-login.sh, e2e-auth.sh) to
prevent Redux Persist state leaking between specs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix Prettier formatting for E2E test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review findings for E2E auth spec

- clickFirstCandidate: check retry result, return null with tree dump on failure
- navigateToHome: throw on failure instead of silently continuing
- 3.2.2: assert exactly 1 Upgrade element (PRO only) and verify PRO visible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for card payment processing flow

Add 8 new E2E tests covering checkout sessions, payment confirmation
via polling, duplicate prevention, and billing events for Stripe card
and Coinbase crypto payment flows.

New files:
- test/e2e/specs/card-payment-flow.spec.ts (8 test cases)
- scripts/e2e-payment.sh (standalone runner)

Modified:
- mock-server.ts: add purchaseError/coinbaseError behavior toggles
- package.json: add test:e2e:payment script and chain into test:e2e

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add assertions to waitForTextToDisappear calls and guard purchaseError cleanup

Ensure all waitForTextToDisappear results are asserted with expect().toBe(true)
instead of silently discarding the return value. Wrap 5.2.2 test body in
try/finally so the purchaseError mock flag is always reset even if assertions throw.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix Prettier formatting in card payment spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix pre-existing Prettier formatting issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for cryptocurrency payment processing flow

Covers invoice creation (Coinbase charge), confirmation handling
(success, underpayment, overpayment), and payment status updates
(polling, API errors, expired charges). Extends mock server with
crypto payment status endpoint and adds test runner script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for Telegram integration flows

Covers sections 7.1-7.5: account linking, permission levels, command
processing, webhook handling, and disconnect/re-setup flows (13 tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for Notion integration flows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for Gmail integration flows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 10:57:12 +04:00

754 lines
22 KiB
TypeScript

/* eslint-disable */
// @ts-nocheck
/**
* Local HTTP mock server for E2E tests.
*
* Replaces the real backend so login-flow tests are fully self-contained.
* Uses only `node:http` and `node:crypto` — no extra npm dependencies.
*
* Also handles WebSocket upgrades for the Socket.IO/Engine.IO endpoint so the
* Rust-native socket manager doesn't crash from repeated connection failures.
*
* Default port: 18473 (high ephemeral, avoids Vite 1420 / Appium 4723 / backend 5005).
*/
import crypto from 'node:crypto';
import http from 'node:http';
const DEFAULT_PORT = 18_473;
// ---------------------------------------------------------------------------
// Request log
// ---------------------------------------------------------------------------
let requestLog = [];
export function getRequestLog() {
return [...requestLog];
}
export function clearRequestLog() {
requestLog = [];
}
// ---------------------------------------------------------------------------
// Mock behavior toggles — tests can change responses at runtime
// ---------------------------------------------------------------------------
let mockBehavior: Record<string, string> = {};
export function setMockBehavior(key: string, value: string) {
mockBehavior[key] = value;
}
export function resetMockBehavior() {
mockBehavior = {};
}
// ---------------------------------------------------------------------------
// Mock data — shapes taken from src/test/handlers.ts (MSW unit-test mocks)
// ---------------------------------------------------------------------------
const MOCK_JWT = 'e2e-mock-jwt-token';
const MOCK_USER = {
_id: 'test-user-123',
telegramId: 12345678,
hasAccess: true,
magicWord: 'alpha',
firstName: 'Test',
lastName: 'User',
username: 'testuser',
role: 'user',
activeTeamId: 'team-1',
referral: {},
subscription: { hasActiveSubscription: false, plan: 'FREE' },
settings: {
dailySummariesEnabled: false,
dailySummaryChatIds: [],
autoCompleteEnabled: false,
autoCompleteVisibility: 'always',
autoCompleteWhitelistChatIds: [],
autoCompleteBlacklistChatIds: [],
},
usage: {
cycleBudgetUsd: 10,
spentThisCycleUsd: 0,
spentTodayUsd: 0,
cycleStartDate: new Date().toISOString(),
},
autoDeleteTelegramMessagesAfterDays: 30,
autoDeleteThreadsAfterDays: 30,
};
// ---------------------------------------------------------------------------
// Dynamic mock data helpers
// ---------------------------------------------------------------------------
/**
* Build a team object whose subscription reflects the current mockBehavior.
* mockBehavior['plan'] → 'FREE' | 'BASIC' | 'PRO' (default: 'FREE')
* mockBehavior['planActive'] → 'true' to mark subscription active
* mockBehavior['planExpiry'] → ISO date string for renewal display
*/
function getMockTeam() {
const plan = mockBehavior['plan'] || 'FREE';
const isActive = mockBehavior['planActive'] === 'true';
const expiry = mockBehavior['planExpiry'] || null;
return {
team: {
_id: 'team-1',
name: 'Personal',
slug: 'personal',
createdBy: 'test-user-123',
isPersonal: true,
maxMembers: 1,
subscription: { plan, hasActiveSubscription: isActive, planExpiry: expiry },
usage: { dailyTokenLimit: 1000, remainingTokens: 1000, activeSessionCount: 0 },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
role: 'ADMIN',
};
}
// ---------------------------------------------------------------------------
// CORS helpers
// ---------------------------------------------------------------------------
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
function setCors(res) {
for (const [key, value] of Object.entries(CORS_HEADERS)) {
res.setHeader(key, value);
}
}
function json(res, status, body) {
setCors(res);
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
// ---------------------------------------------------------------------------
// Route handling (HTTP)
// ---------------------------------------------------------------------------
function readBody(req) {
return new Promise(resolve => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
});
}
async function handleRequest(req, res) {
const method = req.method ?? 'GET';
const url = req.url ?? '/';
const body = await readBody(req);
// Log every request for test assertions
requestLog.push({ method, url, body, timestamp: Date.now() });
// CORS preflight
if (method === 'OPTIONS') {
setCors(res);
res.writeHead(204);
res.end();
return;
}
// Socket.IO polling transport (GET /socket.io/?EIO=4&transport=polling)
// Respond with Engine.IO OPEN packet so polling clients don't error out.
if (url.startsWith('/socket.io/')) {
const eioOpen = JSON.stringify({
sid: 'mock-sid-' + Date.now(),
upgrades: ['websocket'],
pingInterval: 25000,
pingTimeout: 20000,
});
// Engine.IO packet type 0 = OPEN, prefixed with byte count for polling
const packet = `${eioOpen.length + 1}:0${eioOpen}`;
setCors(res);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(packet);
return;
}
// POST /telegram/login-tokens/:token/consume
if (method === 'POST' && /^\/telegram\/login-tokens\/[^/]+\/consume\/?$/.test(url)) {
if (mockBehavior['token'] === 'expired') {
json(res, 401, { success: false, error: 'Token expired or invalid' });
return;
}
if (mockBehavior['token'] === 'invalid') {
json(res, 401, { success: false, error: 'Invalid token' });
return;
}
const jwt = mockBehavior['jwt'] ? `${MOCK_JWT}-${mockBehavior['jwt']}` : MOCK_JWT;
json(res, 200, { success: true, data: { jwtToken: jwt } });
return;
}
// GET /telegram/me
if (method === 'GET' && /^\/telegram\/me\/?(\?.*)?$/.test(url)) {
if (mockBehavior['session'] === 'revoked') {
json(res, 401, { success: false, error: 'Unauthorized' });
return;
}
json(res, 200, { success: true, data: MOCK_USER });
return;
}
// GET /teams
if (method === 'GET' && /^\/teams\/?(\?.*)?$/.test(url)) {
json(res, 200, { success: true, data: [getMockTeam()] });
return;
}
// POST /invite/redeem
if (method === 'POST' && /^\/invite\/redeem\/?$/.test(url)) {
json(res, 200, { success: true, data: { message: 'Invite code redeemed successfully' } });
return;
}
// GET /invite/my-codes
if (method === 'GET' && /^\/invite\/my-codes\/?(\?.*)?$/.test(url)) {
json(res, 200, { success: true, data: [] });
return;
}
// GET /invite/status
if (method === 'GET' && /^\/invite\/status/.test(url)) {
json(res, 200, { success: true, data: { valid: true } });
return;
}
// POST /telegram/settings/onboarding-complete
if (method === 'POST' && /^\/telegram\/settings\/onboarding-complete\/?$/.test(url)) {
json(res, 200, { success: true, data: {} });
return;
}
// GET /billing/current-plan (legacy alias)
// GET /payments/stripe/currentPlan
if (
(method === 'GET' && /^\/billing\/current-plan\/?(\?.*)?$/.test(url)) ||
(method === 'GET' && /^\/payments\/stripe\/currentPlan\/?(\?.*)?$/.test(url))
) {
const plan = mockBehavior['plan'] || 'FREE';
const isActive = mockBehavior['planActive'] === 'true';
const expiry = mockBehavior['planExpiry'] || null;
json(res, 200, {
success: true,
data: {
plan,
hasActiveSubscription: isActive,
planExpiry: expiry,
subscription: isActive
? {
id: 'sub_mock_123',
status: 'active',
currentPeriodEnd: expiry || new Date(Date.now() + 30 * 86400000).toISOString(),
}
: null,
},
});
return;
}
// POST /payments/stripe/purchasePlan
if (method === 'POST' && /^\/payments\/stripe\/purchasePlan\/?$/.test(url)) {
if (mockBehavior['purchaseError'] === 'true') {
json(res, 500, { success: false, error: 'Payment service unavailable' });
return;
}
json(res, 200, {
success: true,
data: {
checkoutUrl: 'http://127.0.0.1:18473/mock-checkout',
sessionId: 'cs_mock_' + Date.now(),
},
});
return;
}
// POST /payments/stripe/portal
if (method === 'POST' && /^\/payments\/stripe\/portal\/?$/.test(url)) {
json(res, 200, { success: true, data: { portalUrl: 'http://127.0.0.1:18473/mock-portal' } });
return;
}
// POST /payments/coinbase/charge
if (method === 'POST' && /^\/payments\/coinbase\/charge\/?$/.test(url)) {
if (mockBehavior['coinbaseError'] === 'true') {
json(res, 500, { success: false, error: 'Coinbase service unavailable' });
return;
}
const chargeId = 'coinbase_mock_' + Date.now();
json(res, 200, {
success: true,
data: {
gatewayTransactionId: chargeId,
hostedUrl: 'http://127.0.0.1:18473/mock-coinbase-checkout',
status: 'NEW',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
},
});
return;
}
// GET /payments/coinbase/charge/:id — crypto payment status check
if (method === 'GET' && /^\/payments\/coinbase\/charge\/[^/]+\/?(\?.*)?$/.test(url)) {
const status = mockBehavior['cryptoStatus'] || 'NEW';
const underpaidAmount = mockBehavior['cryptoUnderpaidAmount'] || '0';
const overpaidAmount = mockBehavior['cryptoOverpaidAmount'] || '0';
json(res, 200, {
success: true,
data: {
status,
payment: {
status,
amountPaid:
status === 'UNDERPAID' ? '150.00' : status === 'OVERPAID' ? '350.00' : '250.00',
amountExpected: '250.00',
currency: 'USDC',
underpaidAmount,
overpaidAmount,
},
expiresAt: new Date(Date.now() + 3600000).toISOString(),
},
});
return;
}
// GET /auth/telegram/connect — OAuth connect URL for Telegram setup
if (method === 'GET' && /^\/auth\/telegram\/connect\/?(\?.*)?$/.test(url)) {
if (mockBehavior['telegramDuplicate'] === 'true') {
json(res, 409, { success: false, error: 'Telegram account already linked to another user' });
return;
}
json(res, 200, {
success: true,
data: { oauthUrl: 'http://127.0.0.1:18473/mock-telegram-oauth' },
});
return;
}
// POST /telegram/command — process a Telegram command
if (method === 'POST' && /^\/telegram\/command\/?$/.test(url)) {
if (mockBehavior['telegramUnauthorized'] === 'true') {
json(res, 403, { success: false, error: 'Unauthorized: insufficient permissions' });
return;
}
if (mockBehavior['telegramCommandError'] === 'true') {
json(res, 400, { success: false, error: 'Invalid command format' });
return;
}
json(res, 200, { success: true, data: { result: 'Command executed successfully' } });
return;
}
// GET /telegram/permissions — get current permission levels
if (method === 'GET' && /^\/telegram\/permissions\/?(\?.*)?$/.test(url)) {
const level = mockBehavior['telegramPermission'] || 'read';
json(res, 200, {
success: true,
data: { level, canRead: true, canWrite: level !== 'read', canInitiate: level === 'admin' },
});
return;
}
// POST /telegram/webhook/configure — configure webhook
if (method === 'POST' && /^\/telegram\/webhook\/configure\/?$/.test(url)) {
json(res, 200, {
success: true,
data: { webhookUrl: 'https://api.example.com/webhook/telegram', active: true },
});
return;
}
// POST /telegram/disconnect — disconnect Telegram skill
if (method === 'POST' && /^\/telegram\/disconnect\/?$/.test(url)) {
json(res, 200, { success: true, data: { disconnected: true } });
return;
}
// GET /skills — list available skills
if (method === 'GET' && /^\/skills\/?(\?.*)?$/.test(url)) {
json(res, 200, {
success: true,
data: [
{
id: 'telegram',
name: 'Telegram',
status: mockBehavior['telegramSkillStatus'] || 'installed',
setupComplete: mockBehavior['telegramSetupComplete'] === 'true',
},
{
id: 'notion',
name: 'Notion',
status: mockBehavior['notionSkillStatus'] || 'installed',
setupComplete: mockBehavior['notionSetupComplete'] === 'true',
},
{
id: 'email',
name: 'Email',
status: mockBehavior['gmailSkillStatus'] || 'installed',
setupComplete: mockBehavior['gmailSetupComplete'] === 'true',
},
],
});
return;
}
// GET /mock-telegram-oauth — mock OAuth page
if (method === 'GET' && /^\/mock-telegram-oauth\/?(\?.*)?$/.test(url)) {
setCors(res);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Mock Telegram OAuth</h1></body></html>');
return;
}
// GET /auth/notion/connect — OAuth connect URL for Notion setup
if (method === 'GET' && /^\/auth\/notion\/connect\/?(\?.*)?$/.test(url)) {
if (mockBehavior['notionTokenRevoked'] === 'true') {
json(res, 401, { success: false, error: 'OAuth token has been revoked' });
return;
}
const workspace = mockBehavior['notionWorkspace'] || "Test User's Workspace";
json(res, 200, {
success: true,
data: { oauthUrl: 'http://127.0.0.1:18473/mock-notion-oauth', workspace },
});
return;
}
// GET /notion/permissions — get current Notion permission level
if (method === 'GET' && /^\/notion\/permissions\/?(\?.*)?$/.test(url)) {
const level = mockBehavior['notionPermission'] || 'read';
json(res, 200, {
success: true,
data: {
level,
canRead: true,
canWrite: level === 'write' || level === 'admin',
canCreate: level === 'write' || level === 'admin',
},
});
return;
}
// GET /mock-notion-oauth — mock Notion OAuth page
if (method === 'GET' && /^\/mock-notion-oauth\/?(\?.*)?$/.test(url)) {
setCors(res);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(
'<html><body><h1>Mock Notion OAuth</h1><p>Authorize workspace access</p></body></html>'
);
return;
}
// GET /auth/google/connect — OAuth connect URL for Gmail/Email setup
if (method === 'GET' && /^\/auth\/google\/connect\/?(\?.*)?$/.test(url)) {
if (mockBehavior['gmailTokenRevoked'] === 'true') {
json(res, 401, { success: false, error: 'OAuth token has been revoked' });
return;
}
if (mockBehavior['gmailTokenExpired'] === 'true') {
json(res, 401, { success: false, error: 'OAuth token has expired' });
return;
}
json(res, 200, {
success: true,
data: { oauthUrl: 'http://127.0.0.1:18473/mock-google-oauth' },
});
return;
}
// GET /gmail/permissions — get current Gmail/Email permission level
if (method === 'GET' && /^\/gmail\/permissions\/?(\?.*)?$/.test(url)) {
const level = mockBehavior['gmailPermission'] || 'read';
json(res, 200, {
success: true,
data: {
level,
canRead: true,
canWrite: level === 'write' || level === 'admin',
canInitiate: level === 'admin',
},
});
return;
}
// POST /gmail/disconnect — disconnect Gmail/Email skill
if (method === 'POST' && /^\/gmail\/disconnect\/?$/.test(url)) {
json(res, 200, { success: true, data: { disconnected: true } });
return;
}
// GET /gmail/emails — fetch emails (mock list)
if (method === 'GET' && /^\/gmail\/emails\/?(\?.*)?$/.test(url)) {
json(res, 200, {
success: true,
data: [
{
id: 'msg-1',
subject: 'Welcome to AlphaHuman',
from: 'team@alphahuman.com',
date: new Date().toISOString(),
snippet: 'Welcome to the platform!',
hasAttachments: false,
},
{
id: 'msg-2',
subject: 'Weekly Crypto Report',
from: 'reports@crypto.com',
date: new Date(Date.now() - 86400000).toISOString(),
snippet: 'Your weekly summary is ready.',
hasAttachments: true,
},
],
});
return;
}
// GET /mock-google-oauth — mock Google OAuth page
if (method === 'GET' && /^\/mock-google-oauth\/?(\?.*)?$/.test(url)) {
setCors(res);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Mock Google OAuth</h1><p>Authorize email access</p></body></html>');
return;
}
// Catch-all — prevents app crashes from unexpected API calls
json(res, 200, { success: true, data: {} });
}
// ---------------------------------------------------------------------------
// WebSocket upgrade handler (minimal Engine.IO + Socket.IO)
//
// The Rust SocketManager connects via WebSocket to
// ws://host/socket.io/?EIO=4&transport=websocket
// and expects:
// 1. Engine.IO OPEN packet (type 0): JSON with sid, pingInterval, etc.
// 2. Socket.IO CONNECT ACK (type 40): JSON with sid
// 3. Periodic Engine.IO PING (type 2) which we respond to with PONG (3)
//
// Without this, the Rust ws_loop retries forever and may destabilize the app.
// ---------------------------------------------------------------------------
function handleWebSocketUpgrade(req, socket, head) {
// Only handle /socket.io/ WebSocket upgrades
if (!req.url?.startsWith('/socket.io/')) {
socket.destroy();
return;
}
// Perform WebSocket handshake (RFC 6455)
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();
// Send Engine.IO OPEN packet (type 0)
const eioOpen = JSON.stringify({
sid: mockSid,
upgrades: [],
pingInterval: 25000,
pingTimeout: 60000,
maxPayload: 1000000,
});
sendWsText(socket, `0${eioOpen}`);
// Buffer for partial frames
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; // need more data
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; // need more data
let payload = buffer.subarray(offset + maskSize, totalLen);
if (masked) {
const mask = buffer.subarray(offset, offset + 4);
payload = Buffer.from(payload); // make writable copy
for (let i = 0; i < payload.length; i++) {
payload[i] ^= mask[i % 4];
}
}
// Consume the frame from the buffer
buffer = buffer.subarray(totalLen);
// Handle by opcode
if (opcode === 0x08) {
// Close
socket.end();
return;
}
if (opcode === 0x09) {
// Ping → Pong
sendWsFrame(socket, 0x0a, payload);
continue;
}
if (opcode === 0x01) {
// Text frame
const text = payload.toString('utf-8');
handleSocketIOMessage(socket, text, mockSid);
}
}
});
socket.on('error', () => {});
socket.on('close', () => {});
}
function handleSocketIOMessage(socket, text, sid) {
// Engine.IO PING (type "2") → respond with PONG ("3")
if (text === '2') {
sendWsText(socket, '3');
return;
}
// Socket.IO CONNECT (type "40") → respond with CONNECT ACK
if (text.startsWith('40')) {
sendWsText(socket, `40{"sid":"${sid}"}`);
return;
}
// Socket.IO EVENT (type "42") → log but ignore
// e.g. 42["tool:sync", {...}]
}
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; // FIN + 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 {
// socket may have been destroyed
}
}
// ---------------------------------------------------------------------------
// Server lifecycle
// ---------------------------------------------------------------------------
let server = null;
const openSockets = new Set();
export function startMockServer(port = DEFAULT_PORT) {
return new Promise((resolve, reject) => {
server = http.createServer((req, res) => {
handleRequest(req, res).catch(err => {
console.error('[MockServer] Unhandled error:', err);
json(res, 500, { success: false, error: 'Internal mock error' });
});
});
// Track all connections so stopMockServer can force-close them
server.on('connection', socket => {
openSockets.add(socket);
socket.on('close', () => openSockets.delete(socket));
});
// Handle WebSocket upgrades for Socket.IO
server.on('upgrade', (req, socket, head) => {
handleWebSocketUpgrade(req, socket, head);
});
server.on('error', reject);
server.listen(port, '127.0.0.1', () => {
console.log(`[MockServer] Listening on http://127.0.0.1:${port}`);
resolve({ port });
});
});
}
export function stopMockServer() {
return new Promise(resolve => {
if (!server) {
resolve();
return;
}
// Destroy all open sockets so server.close() doesn't hang
for (const socket of openSockets) {
socket.destroy();
}
openSockets.clear();
server.close(() => {
console.log('[MockServer] Stopped');
server = null;
resolve();
});
});
}