mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Improve service lifecycle E2E coverage and align CI workflows (#62)
* Add JSON-RPC schema definition and HTTP schema endpoint - Introduced a new `schema.json` file containing detailed definitions for various JSON-RPC methods, including their inputs, outputs, and descriptions. - Implemented a new HTTP endpoint `/schema` in the core server to serve the JSON-RPC schema, enhancing API documentation and accessibility. - Updated the core HTTP router to include the new schema route, improving the overall structure and usability of the API. - Enhanced error handling and response formatting in the server to ensure consistent feedback for schema requests. * Update TypeScript configuration and refactor core RPC client - Changed TypeScript target from ES2020 to ESNext and updated library references in `tsconfig.json` for improved compatibility with modern features. - Refactored `coreRpcClient.ts` to enhance JSON-RPC request handling, including the introduction of legacy method aliases and improved error handling. - Updated API service methods in `authApi.ts` and `channelConnectionsApi.ts` to utilize the new core RPC client structure, streamlining authentication and channel connection processes. - Added new utility functions for managing JSON-RPC requests and responses, improving code organization and maintainability. - Enhanced test coverage for the new RPC client methods and refactored existing tests to align with the updated structure. * Enhance Tauri configuration and refactor daemon program arguments - Updated `tauri.conf.json` to include additional macOS infoPlist settings for better application identification and icon management. - Refactored the `daemon_program_args` function in `common.rs` to improve clarity by renaming the parameter to `_exe`, indicating it is unused. This change enhances code readability and maintainability. * Refactor Tauri configuration by removing unused macOS infoPlist settings - Updated `tauri.conf.json` to streamline macOS configuration by removing unnecessary infoPlist entries while retaining essential settings for the application. - This change enhances clarity and maintainability of the Tauri configuration file. * Enhance authentication flow and testing documentation - Introduced a new `isAuthBootstrapComplete` state in the authentication slice to manage the completion of the authentication bootstrap process. - Updated the `UserProvider` to set the `isAuthBootstrapComplete` state based on the authentication status, improving session restoration logic. - Modified route components (`DefaultRedirect`, `ProtectedRoute`, `PublicRoute`) to conditionally render based on the `isAuthBootstrapComplete` state, enhancing user experience during the authentication process. - Added a comprehensive testing guide in `CLAUDE.md`, detailing unit and E2E testing practices, including setup, authoring rules, and a checklist for test coverage. - Updated the `SettingsHome` component to redirect to the home page instead of the login page upon logout, streamlining user navigation. - Enhanced the `LocalModelPanel` to track download progress and manage local AI assets more effectively, improving overall functionality. * Add resolutions for @tauri-apps/api dependency in package.json - Introduced a resolutions field in package.json to enforce the use of @tauri-apps/api version 2.10.1, ensuring compatibility across workspaces. - Updated dependencies in app/package.json to include @tauri-apps/api version 2.10.1, aligning with the new resolution. - Adjusted yarn.lock to reflect the updated version of @tauri-apps/api, enhancing dependency management and consistency. * Refactor Tauri configuration and enhance E2E build process - Updated `tauri.conf.json` to remove unused resource paths, streamlining the configuration for better maintainability. - Modified `wdio.conf.ts` to improve application path resolution for macOS, allowing for multiple bundle base checks to enhance compatibility. - Refactored `e2e-build.sh` to disable updater artifacts for E2E builds and introduced a conditional cargo clean mechanism, improving build efficiency and clarity. * Enhance E2E testing setup and documentation - Updated `CLAUDE.md` to clarify the default behavior of `OPENHUMAN_WORKSPACE` in `e2e-run-spec.sh`, emphasizing automatic creation and cleanup for reproducible E2E runs. - Modified `e2e-run-spec.sh` to implement automatic temporary workspace creation when `OPENHUMAN_WORKSPACE` is not set, improving usability for debugging and testing. - Enhanced cleanup logic in `e2e-run-spec.sh` to ensure proper removal of temporary workspaces after tests, contributing to a cleaner testing environment. * Implement shared mock backend for testing and enhance documentation - Introduced a shared mock backend for unit and integration tests, allowing for deterministic API behavior across app and Rust tests. - Updated `CLAUDE.md` to include detailed instructions on using the shared mock backend, including key admin endpoints and manual run commands. - Modified `package.json` to add scripts for running the mock API server and Rust tests with the mock backend. - Refactored test setup to utilize the new mock backend, improving test reliability and isolation. - Removed obsolete MSW handlers and server setup, streamlining the testing framework. * Enhance authentication state management and testing coverage - Introduced `isAuthBootstrapComplete` state in the authentication slice to track the completion of the authentication process. - Updated `ProtectedRoute` and `PublicRoute` components to utilize the new state, improving user experience during authentication. - Enhanced test cases for `ProtectedRoute` and `PublicRoute` to reflect the updated authentication state structure. - Added a new end-to-end test for the authentication flow, ensuring proper handling of OAuth tokens and session management. - Improved mock setup in tests to better simulate authentication scenarios, enhancing test reliability and coverage. * Enhance README.md with architecture overview and component roles - Added detailed descriptions of the OpenHuman architecture, highlighting the separation of business logic and UI components. - Explained the roles of Rust and the UI in the monorepo, including the use of JSON-RPC, QuickJS, Vite, React, and Tauri. - Documented the structure of controllers and the RPC surface, emphasizing the shared contract for automation and testing. - Provided links to further documentation for architecture, frontend structure, and Tauri commands. * Integrate ServiceBlockingGate component and enhance loading states - Added the `ServiceBlockingGate` component to manage service availability and display appropriate loading screens. - Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to show a loading indicator while authentication bootstrap is in progress. - Implemented timeout handling in `UserProvider` for improved authentication state management. - Introduced tests for `ServiceBlockingGate` to ensure proper rendering and functionality under various service states. * Implement core RPC URL resolution and default hash route handling - Added a function to resolve the core RPC URL based on the environment, improving flexibility for Tauri and non-Tauri contexts. - Introduced a default hash route handler in the main application entry point to ensure proper navigation behavior. - Updated the core RPC command to expose the resolved RPC URL, enhancing the integration with the frontend. - Refactored the core RPC client to utilize the new URL resolution logic, ensuring consistent API calls. * Refactor backend URL usage to API_BASE_URL - Replaced all instances of BACKEND_URL with API_BASE_URL across various components and services to standardize API endpoint references. - Updated OAuth provider configurations, settings panels, hooks, and services to ensure consistent API calls. - Enhanced test setups to reflect the new API_BASE_URL, improving test reliability and alignment with the updated configuration. * Implement RouteLoadingScreen for improved loading states - Introduced a new `RouteLoadingScreen` component to provide a consistent loading experience across various routes. - Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to utilize `RouteLoadingScreen` while waiting for authentication bootstrap completion. - Enhanced `MiniSidebar` to hide on additional public/setup routes, improving user navigation experience. - Refactored `UserProvider` to streamline authentication state management by removing unnecessary references. * Add feature design workflow section to CLAUDE.md - Introduced a comprehensive workflow for feature design, outlining steps from specification to UI implementation and testing. - Emphasized the importance of grounding designs in existing codebases and defined planning rules for E2E scenarios. - Provided detailed instructions for implementing features in Rust, conducting JSON-RPC tests, and building UI components in the Tauri app. * Refactor backend URL handling and improve OAuth flow - Replaced static API_BASE_URL references with dynamic backend URL resolution across various components and services, enhancing flexibility for Tauri and non-Tauri environments. - Updated OAuth provider configurations to utilize the new backend URL logic, ensuring consistent login URL generation. - Refactored API client and socket service to fetch the backend URL dynamically, improving reliability in different deployment contexts. - Introduced a new service for resolving the backend URL, streamlining the configuration and enhancing test setups. * Add debug logging guidelines to CLAUDE.md - Introduced comprehensive guidelines for implementing development-oriented debug logging in both Rust and the app. - Emphasized the importance of logging at appropriate levels (`debug`/`trace`) and following existing patterns for consistency. - Provided instructions on avoiding sensitive information in logs and ensuring terminal output is grep-friendly for easier debugging during development. * Enhance service management with new mock functionality and E2E tests - Added a mock service manager to facilitate deterministic service behavior during end-to-end tests, enabling better simulation of service states. - Introduced new buttons in the `ServiceBlockingGate` component for restarting and uninstalling services, improving user control over service management. - Implemented a comprehensive E2E test suite for the service connectivity flow, covering installation, starting, stopping, restarting, and uninstalling services. - Updated package scripts to include a new E2E test for service connectivity, enhancing testing coverage and reliability. - Refactored service operations to support mock functionality, ensuring consistent behavior across testing and production environments. * Enhance ServiceBlockingGate with improved logging and periodic health polling - Introduced periodic health polling in the `ServiceBlockingGate` component to refresh service status every 3 seconds, enhancing responsiveness to service state changes. - Added detailed logging for various operations, including service status checks and error handling, to improve traceability and debugging. - Updated E2E tests to include logging steps for better visibility during service connectivity flow tests. - Refactored error handling to ensure consistent logging of error messages across service operations. * Refactor E2E testing scripts and enhance CLAUDE.md documentation - Updated paths in CLAUDE.md to reflect the new location of the E2E run script, ensuring accurate instructions for running tests. - Removed outdated E2E test scripts from package.json and migrated relevant functionality to app/package.json for better organization. - Introduced new E2E testing scripts for specific flows (auth, login, payment, etc.) to streamline testing processes and improve modularity. - Added a script to run all E2E flows sequentially, enhancing test coverage and simplifying execution. - Improved documentation for E2E testing procedures in CLAUDE.md, providing clearer guidance for developers. * Refactor imports and enhance code readability - Removed duplicate import of `ServiceBlockingGate` in `App.tsx` for cleaner code. - Improved readability in `MiniSidebar.tsx` by formatting conditional statements. - Reordered imports in `PublicRoute.tsx` for consistency. - Enhanced formatting in `ServiceBlockingGate.tsx` for better clarity in asynchronous operations. - Streamlined import statements in various test files and components for improved organization. - Updated `LocalModelPanel.tsx` to enhance button disable logic readability. - Refactored CORS headers in `jsonrpc.rs` for better formatting. * ci: align build and test workflows with app workspace e2e * ci: align release and typecheck workflows with current workspace * Enhance ServiceBlockingGate functionality with improved refresh options - Introduced a new `RefreshOptions` type to customize the behavior of the `refreshStatus` function, allowing for conditional error clearing and status checking. - Updated the `refreshStatus` function to utilize the new options, enhancing control over service state updates. - Modified the button click handler to pass the new options, improving user experience during service refresh operations. - Adjusted state updates to prevent unnecessary re-renders and maintain consistency in service state management. * Refactor RefreshOptions type for improved clarity in ServiceBlockingGate - Consolidated the definition of the `RefreshOptions` type into a single line for better readability. - Maintained existing functionality while enhancing code clarity in the `ServiceBlockingGate` component.
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E auth & access control tests only. See scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/auth-access-control.spec.ts" "auth"
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the .app bundle for E2E tests with the mock server URL baked in.
|
||||
#
|
||||
# This does a cargo clean first to ensure the frontend assets are re-embedded
|
||||
# (Cargo's incremental build won't detect changes to dist/).
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_DIR="$REPO_ROOT/app"
|
||||
cd "$APP_DIR"
|
||||
|
||||
# Source Cargo environment
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
|
||||
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}"
|
||||
|
||||
echo "Building E2E app bundle with VITE_BACKEND_URL=$VITE_BACKEND_URL"
|
||||
|
||||
if [ -z "${E2E_SKIP_CARGO_CLEAN:-}" ]; then
|
||||
cargo clean --manifest-path src-tauri/Cargo.toml
|
||||
else
|
||||
echo "Skipping cargo clean (E2E_SKIP_CARGO_CLEAN is set)."
|
||||
fi
|
||||
|
||||
if [ -f .env ]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$REPO_ROOT/scripts/load-dotenv.sh"
|
||||
else
|
||||
echo "No .env file — skipping load-dotenv (optional for CI)."
|
||||
fi
|
||||
|
||||
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}"
|
||||
|
||||
# Stage rust-core sidecar for bundle.externalBin (see app/src-tauri/tauri.conf.json).
|
||||
node "$REPO_ROOT/scripts/stage-core-sidecar.mjs"
|
||||
|
||||
# Use npx so CI does not require a global Tauri CLI (cwd must be the Tauri frontend root).
|
||||
npx tauri build --bundles app --debug
|
||||
|
||||
echo "E2E build complete."
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E crypto payment flow tests only. See scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/crypto-payment-flow.spec.ts" "crypto-payment"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E Gmail integration flow tests only. See scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/gmail-flow.spec.ts" "gmail"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E login flow tests only. See scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/login-flow.spec.ts" "login"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E Notion integration flow tests only. See scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/notion-flow.spec.ts" "notion"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E card payment flow tests only. See scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/card-payment-flow.spec.ts" "card-payment"
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolve Node 24+ and Appium for E2E scripts (local nvm or CI PATH).
|
||||
# shellcheck disable=SC2034
|
||||
# Outputs: NODE24, APPIUM_BIN (export for callers)
|
||||
|
||||
NODE24="$(command -v node 2>/dev/null || true)"
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$NVM_DIR/nvm.sh"
|
||||
NVM_NODE="$(nvm which 24 2>/dev/null || true)"
|
||||
if [ -n "${NVM_NODE:-}" ] && [ -x "$NVM_NODE" ]; then
|
||||
NODE24="$NVM_NODE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${NODE24:-}" ] || [ ! -x "$NODE24" ]; then
|
||||
echo "ERROR: Node.js is required (Node 24+ for Appium v3)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_MAJOR="$("$NODE24" --version | sed 's/^v//' | cut -d. -f1)"
|
||||
if [ "${NODE_MAJOR:-0}" -lt 24 ]; then
|
||||
echo "ERROR: Node 24+ is required for Appium v3 (found $($NODE24 --version))." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APPIUM_BIN="$(command -v appium 2>/dev/null || true)"
|
||||
if [ -z "${APPIUM_BIN:-}" ] || [ ! -x "$APPIUM_BIN" ]; then
|
||||
APPIUM_BIN="$(dirname "$NODE24")/appium"
|
||||
fi
|
||||
if [ ! -x "$APPIUM_BIN" ]; then
|
||||
echo "ERROR: appium not found. Install with: npm install -g appium" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export NODE24
|
||||
export APPIUM_BIN
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run all E2E WDIO specs sequentially (Appium restarted per spec).
|
||||
# Requires a prior E2E app build: yarn test:e2e:build
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT/app"
|
||||
|
||||
run() {
|
||||
"$ROOT/scripts/e2e-run-spec.sh" "$1" "$2"
|
||||
}
|
||||
|
||||
run "test/e2e/specs/login-flow.spec.ts" "login"
|
||||
run "test/e2e/specs/auth-access-control.spec.ts" "auth"
|
||||
run "test/e2e/specs/telegram-flow.spec.ts" "telegram"
|
||||
run "test/e2e/specs/gmail-flow.spec.ts" "gmail"
|
||||
run "test/e2e/specs/notion-flow.spec.ts" "notion"
|
||||
run "test/e2e/specs/card-payment-flow.spec.ts" "card-payment"
|
||||
run "test/e2e/specs/crypto-payment-flow.spec.ts" "crypto-payment"
|
||||
run "test/e2e/specs/navigation.spec.ts" "navigation"
|
||||
run "test/e2e/specs/smoke.spec.ts" "smoke"
|
||||
run "test/e2e/specs/tauri-commands.spec.ts" "tauri-commands"
|
||||
|
||||
echo "All E2E flows completed."
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run a single WebDriverIO E2E spec (Appium mac2 + mock server in spec).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/e2e-run-spec.sh test/e2e/specs/login-flow.spec.ts [log-suffix]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SPEC="${1:?spec path required}"
|
||||
LOG_SUFFIX="${2:-$(basename "$SPEC" .spec.ts)}"
|
||||
|
||||
APPIUM_PORT="${APPIUM_PORT:-4723}"
|
||||
E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP_DIR="$REPO_ROOT/app"
|
||||
cd "$APP_DIR"
|
||||
# shellcheck source=/dev/null
|
||||
source "$SCRIPT_DIR/e2e-resolve-node-appium.sh"
|
||||
|
||||
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
|
||||
export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
|
||||
|
||||
echo "Cleaning cached app data..."
|
||||
rm -rf ~/Library/WebKit/com.openhuman.app
|
||||
rm -rf ~/Library/Caches/com.openhuman.app
|
||||
rm -rf "$HOME/Library/Application Support/com.openhuman.app"
|
||||
|
||||
DIST_JS="$(ls dist/assets/index-*.js 2>/dev/null | head -1)"
|
||||
if [ -z "$DIST_JS" ]; then
|
||||
echo "ERROR: No frontend bundle found at dist/assets/index-*.js." >&2
|
||||
echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then
|
||||
echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2
|
||||
echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified: frontend bundle contains mock server URL."
|
||||
|
||||
APPIUM_LOG="/tmp/appium-e2e-${LOG_SUFFIX}.log"
|
||||
NODE_VER=$("$NODE24" --version)
|
||||
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
|
||||
echo " Appium logs: $APPIUM_LOG"
|
||||
"$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 &
|
||||
APPIUM_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "Stopping Appium (pid $APPIUM_PID)..."
|
||||
kill "$APPIUM_PID" 2>/dev/null || true
|
||||
wait "$APPIUM_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then
|
||||
echo "Appium is ready."
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "ERROR: Appium did not start within 30 seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Running E2E spec ($SPEC)..."
|
||||
npx wdio run test/wdio.conf.ts --spec "$SPEC"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E Telegram integration flow tests only. See scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/telegram-flow.spec.ts" "telegram"
|
||||
@@ -0,0 +1,705 @@
|
||||
import crypto from "node:crypto";
|
||||
import http from "node:http";
|
||||
|
||||
const DEFAULT_PORT = 18473;
|
||||
const MOCK_JWT = "e2e-mock-jwt-token";
|
||||
|
||||
let requestLog = [];
|
||||
let mockBehavior = {};
|
||||
let server = null;
|
||||
const openSockets = new Set();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function html(res, status, body) {
|
||||
setCors(res);
|
||||
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function requestOrigin(req) {
|
||||
const host = req.headers.host || "127.0.0.1:18473";
|
||||
return `http://${host}`;
|
||||
}
|
||||
|
||||
function getMockUser() {
|
||||
return {
|
||||
_id: "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,
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestLog() {
|
||||
return [...requestLog];
|
||||
}
|
||||
|
||||
function clearRequestLog() {
|
||||
requestLog = [];
|
||||
}
|
||||
|
||||
function setMockBehavior(key, value) {
|
||||
mockBehavior[key] = String(value);
|
||||
}
|
||||
|
||||
function setMockBehaviors(behavior, mode = "merge") {
|
||||
if (mode === "replace") {
|
||||
mockBehavior = {};
|
||||
}
|
||||
for (const [key, value] of Object.entries(behavior || {})) {
|
||||
mockBehavior[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function resetMockBehavior() {
|
||||
mockBehavior = {};
|
||||
}
|
||||
|
||||
function getMockBehavior() {
|
||||
return { ...mockBehavior };
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
||||
});
|
||||
}
|
||||
|
||||
function tryParseJson(raw) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
const method = req.method ?? "GET";
|
||||
const url = req.url ?? "/";
|
||||
const body = await readBody(req);
|
||||
const parsedBody = tryParseJson(body);
|
||||
const origin = requestOrigin(req);
|
||||
|
||||
requestLog.push({ method, url, body, timestamp: Date.now() });
|
||||
|
||||
if (method === "OPTIONS") {
|
||||
setCors(res);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/__admin\/health\/?$/.test(url)) {
|
||||
json(res, 200, { ok: true, port: server?.address()?.port ?? null });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/__admin\/requests\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: getRequestLog() });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/__admin\/behavior\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: getMockBehavior() });
|
||||
return;
|
||||
}
|
||||
if (method === "POST" && /^\/__admin\/reset\/?$/.test(url)) {
|
||||
const keepBehavior = parsedBody?.keepBehavior === true;
|
||||
const keepRequests = parsedBody?.keepRequests === true;
|
||||
if (!keepBehavior) resetMockBehavior();
|
||||
if (!keepRequests) clearRequestLog();
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { behavior: getMockBehavior(), requestCount: getRequestLog().length },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "POST" && /^\/__admin\/behavior\/?$/.test(url)) {
|
||||
if (parsedBody?.behavior && typeof parsedBody.behavior === "object") {
|
||||
setMockBehaviors(parsedBody.behavior, parsedBody.mode);
|
||||
} else if (parsedBody?.key) {
|
||||
setMockBehavior(parsedBody.key, parsedBody.value ?? "");
|
||||
}
|
||||
json(res, 200, { success: true, data: getMockBehavior() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith("/socket.io/")) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/auth\/desktop-exchange\/?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
sessionToken: "mock-session-token",
|
||||
user: { id: "user-123", firstName: "Test", username: "testuser" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/telegram\/me\/?(\?.*)?$/.test(url)) {
|
||||
if (mockBehavior.telegramMeStatus) {
|
||||
const status = Number(mockBehavior.telegramMeStatus) || 500;
|
||||
json(res, status, {
|
||||
success: false,
|
||||
error: mockBehavior.telegramMeError || "Mock telegram/me failure",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mockBehavior.session === "revoked") {
|
||||
json(res, 401, { success: false, error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { success: true, data: getMockUser() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/settings\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { _id: "e2e-user-1", username: "e2e" } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/teams\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: [getMockTeam()] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/auth\/integrations\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/openai\/v1\/models\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { data: [{ id: "e2e-mock-model", object: "model" }] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/openai\/v1\/chat\/completions\/?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
choices: [{ message: { role: "assistant", content: "Hello from e2e mock agent" } }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/auth\/[^/]+\/login\/?(\?.*)?$/.test(url)) {
|
||||
const redirectUrl = `${origin}/mock-oauth`;
|
||||
if (url.includes("responseType=json")) {
|
||||
json(res, 200, { success: true, data: { oauthUrl: redirectUrl } });
|
||||
return;
|
||||
}
|
||||
setCors(res);
|
||||
res.writeHead(302, { Location: redirectUrl });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
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: `${origin}/mock-telegram-oauth` } });
|
||||
return;
|
||||
}
|
||||
|
||||
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: `${origin}/mock-notion-oauth`, workspace },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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: `${origin}/mock-google-oauth` } });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/telegram\/webhook\/configure\/?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { webhookUrl: "https://api.example.com/webhook/telegram", active: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/telegram\/disconnect\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { disconnected: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/notion\/permissions\/?(\?.*)?$/.test(url)) {
|
||||
const level = mockBehavior.notionPermission || "read";
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { level, canRead: true, canWrite: level !== "read", canCreate: level !== "read" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/gmail\/permissions\/?(\?.*)?$/.test(url)) {
|
||||
const level = mockBehavior.gmailPermission || "read";
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { level, canRead: true, canWrite: level !== "read", canInitiate: level === "admin" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/gmail\/disconnect\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { disconnected: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/gmail\/emails\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: "msg-1",
|
||||
subject: "Welcome to OpenHuman",
|
||||
from: "team@openhuman.com",
|
||||
date: new Date().toISOString(),
|
||||
snippet: "Welcome to the platform!",
|
||||
hasAttachments: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/invite\/redeem\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { message: "Invite code redeemed successfully" } });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/invite\/my-codes\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/invite\/status/.test(url)) {
|
||||
json(res, 200, { success: true, data: { valid: true } });
|
||||
return;
|
||||
}
|
||||
if (method === "POST" && /^\/telegram\/settings\/onboarding-complete\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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: `${origin}/mock-checkout`, sessionId: `cs_mock_${Date.now()}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/payments\/stripe\/portal\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { portalUrl: `${origin}/mock-portal` } });
|
||||
return;
|
||||
}
|
||||
|
||||
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: `${origin}/mock-coinbase-checkout`,
|
||||
status: "NEW",
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/payments\/coinbase\/charge\/[^/]+\/?(\?.*)?$/.test(url)) {
|
||||
const status = mockBehavior.cryptoStatus || "NEW";
|
||||
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: mockBehavior.cryptoUnderpaidAmount || "0",
|
||||
overpaidAmount: mockBehavior.cryptoOverpaidAmount || "0",
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/mock-(telegram|notion|google)-oauth\/?(\?.*)?$/.test(url)) {
|
||||
html(res, 200, "<html><body><h1>Mock OAuth</h1></body></html>");
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/mock-oauth\/?(\?.*)?$/.test(url)) {
|
||||
html(res, 200, "<html><body><h1>Mock OAuth Redirect</h1></body></html>");
|
||||
return;
|
||||
}
|
||||
|
||||
json(res, 200, { success: true, data: {} });
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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", () => {});
|
||||
}
|
||||
|
||||
function startMockServer(port = DEFAULT_PORT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (server) {
|
||||
resolve({ port: server.address()?.port ?? port, alreadyRunning: true });
|
||||
return;
|
||||
}
|
||||
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" });
|
||||
});
|
||||
});
|
||||
server.on("connection", (socket) => {
|
||||
openSockets.add(socket);
|
||||
socket.on("close", () => openSockets.delete(socket));
|
||||
});
|
||||
server.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket));
|
||||
server.on("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`[MockServer] Listening on http://127.0.0.1:${port}`);
|
||||
resolve({ port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stopMockServer() {
|
||||
return new Promise((resolve) => {
|
||||
if (!server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
for (const socket of openSockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
openSockets.clear();
|
||||
server.close(() => {
|
||||
console.log("[MockServer] Stopped");
|
||||
server = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_PORT,
|
||||
clearRequestLog,
|
||||
getMockBehavior,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
setMockBehavior,
|
||||
setMockBehaviors,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
};
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
import process from "node:process";
|
||||
|
||||
import { DEFAULT_PORT, startMockServer, stopMockServer } from "./mock-api-core.mjs";
|
||||
|
||||
function readPortArg() {
|
||||
const idx = process.argv.findIndex((arg) => arg === "--port" || arg === "-p");
|
||||
if (idx >= 0 && process.argv[idx + 1]) {
|
||||
const value = Number(process.argv[idx + 1]);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
const envPort = Number(process.env.MOCK_API_PORT || process.env.E2E_MOCK_PORT || DEFAULT_PORT);
|
||||
return Number.isInteger(envPort) && envPort > 0 ? envPort : DEFAULT_PORT;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = readPortArg();
|
||||
await startMockServer(port);
|
||||
const shutdown = async () => {
|
||||
await stopMockServer();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[mock-api-server] Failed to start:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run Rust tests against the shared mock backend.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-rust-with-mock.sh
|
||||
# ./scripts/test-rust-with-mock.sh --test json_rpc_e2e
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
MOCK_API_PORT="${MOCK_API_PORT:-18505}"
|
||||
MOCK_API_URL="http://127.0.0.1:${MOCK_API_PORT}"
|
||||
MOCK_LOG="${MOCK_LOG:-/tmp/openhuman-mock-api.log}"
|
||||
MOCK_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$MOCK_PID" ]; then
|
||||
kill "$MOCK_PID" 2>/dev/null || true
|
||||
wait "$MOCK_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting mock API server on ${MOCK_API_URL} ..."
|
||||
node "$SCRIPT_DIR/mock-api-server.mjs" --port "$MOCK_API_PORT" >"$MOCK_LOG" 2>&1 &
|
||||
MOCK_PID=$!
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "${MOCK_API_URL}/__admin/health" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "ERROR: mock API server did not become healthy in time." >&2
|
||||
echo "See logs: $MOCK_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
export BACKEND_URL="$MOCK_API_URL"
|
||||
export VITE_BACKEND_URL="$MOCK_API_URL"
|
||||
|
||||
echo "Running Rust tests with BACKEND_URL=$BACKEND_URL"
|
||||
cd "$REPO_ROOT"
|
||||
source "$HOME/.cargo/env" 2>/dev/null || true
|
||||
cargo test --manifest-path Cargo.toml --workspace "$@"
|
||||
Reference in New Issue
Block a user