From 9694dbee3ca942873815762073518202e437720a Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 13 May 2026 08:43:03 -0700 Subject: [PATCH] refactor(mock-api): domain split + OAuth + Composio + e2e workflow + macOS mega-spec scaffold (#1532) --- .github/workflows/e2e.yml | 245 ++++ .gitignore | 4 + app/scripts/e2e-build.sh | 37 +- app/scripts/e2e-run-spec.sh | 145 +- app/src-tauri/src/core_process.rs | 38 + app/test/e2e/helpers/core-rpc-node.ts | 43 +- app/test/e2e/specs/mega-flow.spec.ts | 288 ++++ app/test/wdio.conf.ts | 16 +- scripts/cancel-stale-pr-ci.mjs | 256 ++++ scripts/merge-main-into-open-prs.mjs | 382 ++++++ scripts/mock-api-core.mjs | 1513 +-------------------- scripts/mock-api/admin.mjs | 52 + scripts/mock-api/http.mjs | 74 + scripts/mock-api/index.mjs | 20 + scripts/mock-api/routes/auth.mjs | 145 ++ scripts/mock-api/routes/conversations.mjs | 102 ++ scripts/mock-api/routes/cron.mjs | 63 + scripts/mock-api/routes/integrations.mjs | 293 ++++ scripts/mock-api/routes/invites.mjs | 26 + scripts/mock-api/routes/oauth.mjs | 228 ++++ scripts/mock-api/routes/payments.mjs | 262 ++++ scripts/mock-api/routes/user.mjs | 298 ++++ scripts/mock-api/routes/version.mjs | 22 + scripts/mock-api/routes/webhooks.mjs | 89 ++ scripts/mock-api/server.mjs | 211 +++ scripts/mock-api/socket.mjs | 142 ++ scripts/mock-api/state.mjs | 154 +++ scripts/review/README.md | 15 +- scripts/review/cli.sh | 9 +- scripts/review/coverage.sh | 88 ++ 30 files changed, 3669 insertions(+), 1591 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 app/test/e2e/specs/mega-flow.spec.ts create mode 100644 scripts/cancel-stale-pr-ci.mjs create mode 100644 scripts/merge-main-into-open-prs.mjs create mode 100644 scripts/mock-api/admin.mjs create mode 100644 scripts/mock-api/http.mjs create mode 100644 scripts/mock-api/index.mjs create mode 100644 scripts/mock-api/routes/auth.mjs create mode 100644 scripts/mock-api/routes/conversations.mjs create mode 100644 scripts/mock-api/routes/cron.mjs create mode 100644 scripts/mock-api/routes/integrations.mjs create mode 100644 scripts/mock-api/routes/invites.mjs create mode 100644 scripts/mock-api/routes/oauth.mjs create mode 100644 scripts/mock-api/routes/payments.mjs create mode 100644 scripts/mock-api/routes/user.mjs create mode 100644 scripts/mock-api/routes/version.mjs create mode 100644 scripts/mock-api/routes/webhooks.mjs create mode 100644 scripts/mock-api/server.mjs create mode 100644 scripts/mock-api/socket.mjs create mode 100644 scripts/mock-api/state.mjs create mode 100644 scripts/review/coverage.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..bf77f86e4 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,245 @@ +--- +# Dedicated end-to-end test workflow. +# +# Runs the WDIO desktop e2e suite against the mock backend +# (`scripts/mock-api/`) on three OSes in parallel: +# +# - linux → containerized in `ghcr.io/tinyhumansai/openhuman_ci:latest` +# (the same image used by `unit-tests` and by +# `e2e/docker-compose.yml`), Xvfb-backed tauri-driver. +# CEF caveat: tauri-driver / webkit2gtk cannot drive the CEF +# WebView's DOM, so this leg runs only specs that don't depend +# on accessibility-tree queries (smoke). +# +# - macos → bare runner, Appium Mac2 driver. Canonical e2e runner. +# Runs smoke + the mega-flow spec (the latter best-effort +# until the BootCheckGate local-mode bypass lands). +# +# - windows → bare runner, tauri-driver via WebView2. Smoke only for now; +# Windows hasn't been wired up for the broader spec suite. +# +# Triggers: +# - pull_request (smoke only on every PR — cheap signal that the bundle +# still builds and launches on every OS). +# - workflow_dispatch with `full=true` to run the entire spec suite on +# macOS (the only platform where the broader specs are designed to pass +# today). +# +# All three legs are `continue-on-error` so a known-flaky platform doesn't +# block the PR — the macOS leg is the source of truth for spec correctness. +name: E2E + +on: + pull_request: + workflow_dispatch: + inputs: + full: + description: Run the full e2e spec suite on macOS (slow; ~30+ min) + required: false + default: 'false' + type: choice + options: ['false', 'true'] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + # --------------------------------------------------------------------------- + # Linux — build-only. tauri-driver speaks WebKitWebDriver / webkit2gtk and + # cannot drive the CEF-backed WebView, so we can't run specs against the + # Linux Tauri build yet. We still build the Linux .deb-style debug binary + # so a Linux-breaking dep / config / Rust change still fails the PR. + # Reuses the project's CI image (same one consumed by + # `e2e/docker-compose.yml` and the `unit-tests` job). + # --------------------------------------------------------------------------- + e2e-linux: + name: E2E (Linux build only) + runs-on: ubuntu-22.04 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:latest + timeout-minutes: 45 + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + submodules: recursive + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ~/.local/share/pnpm/store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + app/src-tauri -> target + cache-on-failure: true + key: e2e-linux + + - name: Install JS dependencies + run: pnpm install --frozen-lockfile + + - name: Ensure .env exists for E2E build + run: | + touch .env + touch app/.env + + - name: Build E2E app (Linux debug binary) + run: pnpm --filter openhuman-app test:e2e:build + + # --------------------------------------------------------------------------- + # macOS — Appium Mac2. Canonical e2e runner. + # --------------------------------------------------------------------------- + e2e-macos: + name: E2E (macOS / Appium Mac2) + runs-on: macos-latest + timeout-minutes: 90 + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + submodules: recursive + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js 24.x + uses: actions/setup-node@v5 + with: + node-version: 24.x + cache: pnpm + + - name: Install Rust (rust-toolchain.toml) + uses: dtolnay/rust-toolchain@1.93.0 + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + app/src-tauri -> target + cache-on-failure: true + key: e2e-macos + + - name: Accept Xcode license + first launch + run: | + sudo xcodebuild -license accept + sudo xcodebuild -runFirstLaunch + + - name: Install JS dependencies + run: pnpm install --frozen-lockfile + + - name: Ensure .env exists for E2E build + run: | + touch .env + touch app/.env + + - name: Install Appium and mac2 driver + run: | + npm install -g appium + appium driver install mac2 + + - name: Build E2E app bundle + run: pnpm --filter openhuman-app test:e2e:build + + - name: Run smoke spec + run: bash app/scripts/e2e-run-spec.sh test/e2e/specs/smoke.spec.ts smoke + + - name: Run mega-flow spec + continue-on-error: true + run: bash app/scripts/e2e-run-spec.sh test/e2e/specs/mega-flow.spec.ts mega-flow + + - name: Run full e2e suite (workflow_dispatch only) + if: github.event_name == 'workflow_dispatch' && github.event.inputs.full == 'true' + run: pnpm --filter openhuman-app test:e2e:all:flows + + - name: Upload e2e artifacts on failure + if: failure() || cancelled() + uses: actions/upload-artifact@v5 + with: + name: e2e-artifacts-macos + path: app/test/e2e/artifacts/ + if-no-files-found: ignore + retention-days: 7 + + # --------------------------------------------------------------------------- + # Windows — build-only. tauri-driver on Windows uses MSEdgeDriver + # (WebView2) which can't drive the CEF-backed WebView the app actually + # ships — observed `session not created: Microsoft Edge failed to start: + # crashed.` when the driver tried to launch Edge for the WebView2 host + # that doesn't exist on our app. Same shape as the Linux block (CEF ↔ + # webkit2gtk-driver). Build the .exe so a Windows-breaking dep / config + # / Rust change still fails the PR; skip the spec run until a + # CEF-compatible Windows driver lands. + # --------------------------------------------------------------------------- + e2e-windows: + name: E2E (Windows build only) + runs-on: windows-latest + timeout-minutes: 60 + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + submodules: recursive + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js 24.x + uses: actions/setup-node@v5 + with: + node-version: 24.x + cache: pnpm + + - name: Install Rust (rust-toolchain.toml) + uses: dtolnay/rust-toolchain@1.93.0 + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + app/src-tauri -> target + cache-on-failure: true + # `v2` suffix invalidates the prior cache that ended up pinning + # stale .rmeta artifacts (saw `error[E0061]: sys.refresh_processes` + # referencing a `local_ai/install.rs` line that doesn't exist on + # this branch — cargo cross-referenced an outdated .rmeta). + key: e2e-windows-v2 + + - name: Install JS dependencies + run: pnpm install --frozen-lockfile + + - name: Ensure .env exists for E2E build + shell: bash + run: | + touch .env + touch app/.env + + - name: Build E2E app (Windows debug .exe) + run: pnpm --filter openhuman-app test:e2e:build + + - name: Upload e2e artifacts on failure + if: failure() || cancelled() + uses: actions/upload-artifact@v5 + with: + name: e2e-artifacts-windows + path: app/test/e2e/artifacts/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index a09d3153a..8a3486f82 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,10 @@ wdio-logs/ test-results/ coverage/ app/public/generated/remotion/ +app/test/e2e/artifacts/ + +# Local pnpm store (created when network-restricted hosts cache the store inside the repo) +.pnpm-store/ tauri.key tauri.key.pub diff --git a/app/scripts/e2e-build.sh b/app/scripts/e2e-build.sh index 9942ee539..f44eddd42 100755 --- a/app/scripts/e2e-build.sh +++ b/app/scripts/e2e-build.sh @@ -36,8 +36,8 @@ 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" +# Core is compiled in-process into the Tauri shell as of PR #1061; the old +# scripts/stage-core-sidecar.mjs staging step is no longer needed. # Disable updater artifacts for E2E bundles to avoid signing-key requirements. TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":false}}' @@ -45,14 +45,29 @@ TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":false}}' case "${CI:-}" in 1) export CI=true ;; 0) export CI=false ;; esac OS="$(uname)" -if [ "$OS" = "Linux" ]; then - # Linux: build debug binary only (no bundle needed for tauri-driver) - echo "Building for Linux (debug binary, no bundle)..." - pnpm exec tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle -else - # macOS: build .app bundle for Appium Mac2 - echo "Building for macOS (.app bundle)..." - pnpm exec tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles app --debug -fi +case "$OS" in + Linux) + # Linux: build debug binary only (no bundle needed for tauri-driver) + echo "Building for Linux (debug binary, no bundle)..." + pnpm exec tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle + ;; + Darwin) + # macOS: build .app bundle for Appium Mac2 (wdio.conf points at + # src-tauri/target/debug/bundle/macos/OpenHuman.app). + echo "Building for macOS (.app bundle)..." + pnpm exec tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles app --debug + ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + # Windows: bare .exe at src-tauri/target/debug/OpenHuman.exe is what + # wdio.conf launches via tauri-driver. NSIS/MSI bundling adds time we + # don't need for the driver path. + echo "Building for Windows (.exe, no bundle)..." + pnpm exec tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle + ;; + *) + echo "ERROR: unsupported OS for e2e build: $OS" >&2 + exit 1 + ;; +esac echo "E2E build complete." diff --git a/app/scripts/e2e-run-spec.sh b/app/scripts/e2e-run-spec.sh index d9e4a14b1..f4831378e 100755 --- a/app/scripts/e2e-run-spec.sh +++ b/app/scripts/e2e-run-spec.sh @@ -123,67 +123,94 @@ if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then fi echo "Verified: frontend bundle contains mock server URL." -if [ "$OS" = "Linux" ]; then - # --------------------------------------------------------------------------- - # Linux: start tauri-driver - # --------------------------------------------------------------------------- - export TAURI_DRIVER_PORT="${TAURI_DRIVER_PORT:-4444}" - DRIVER_LOG="/tmp/tauri-driver-e2e-${LOG_SUFFIX}.log" +case "$OS" in + Linux|MINGW*|MSYS*|CYGWIN*|Windows_NT) + # ------------------------------------------------------------------------- + # Linux + Windows: start tauri-driver (WebKitWebDriver/Linux, + # MSEdgeDriver/Windows under the hood). + # ------------------------------------------------------------------------- + export TAURI_DRIVER_PORT="${TAURI_DRIVER_PORT:-4444}" + case "$OS" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) + DRIVER_LOG="${TMP:-${TEMP:-${TMPDIR:-/tmp}}}/tauri-driver-e2e-${LOG_SUFFIX}.log" + TAURI_DRIVER_NAME="tauri-driver.exe" + ;; + *) + DRIVER_LOG="/tmp/tauri-driver-e2e-${LOG_SUFFIX}.log" + TAURI_DRIVER_NAME="tauri-driver" + ;; + esac - TAURI_DRIVER_BIN="$(command -v tauri-driver 2>/dev/null || true)" - if [ -z "${TAURI_DRIVER_BIN:-}" ] || [ ! -x "$TAURI_DRIVER_BIN" ]; then - # Try cargo bin path - TAURI_DRIVER_BIN="$HOME/.cargo/bin/tauri-driver" - fi - if [ ! -x "$TAURI_DRIVER_BIN" ]; then - echo "ERROR: tauri-driver not found. Install with: cargo install tauri-driver" >&2 + TAURI_DRIVER_BIN="$(command -v "$TAURI_DRIVER_NAME" 2>/dev/null || true)" + if [ -z "${TAURI_DRIVER_BIN:-}" ] || [ ! -x "$TAURI_DRIVER_BIN" ]; then + # Prefer the POSIX $HOME/.cargo/bin path so `-x` works in git-bash on + # Windows. Only fall back to %USERPROFILE% (converted to a POSIX path + # via cygpath if available) when $HOME doesn't have the binary — + # that covers cases where the runner's HOME is overridden. + TAURI_DRIVER_BIN="$HOME/.cargo/bin/$TAURI_DRIVER_NAME" + if [ ! -x "$TAURI_DRIVER_BIN" ] && [ -n "${USERPROFILE:-}" ]; then + if command -v cygpath >/dev/null 2>&1; then + TAURI_DRIVER_BIN="$(cygpath -u "$USERPROFILE")/.cargo/bin/$TAURI_DRIVER_NAME" + else + TAURI_DRIVER_BIN="$USERPROFILE/.cargo/bin/$TAURI_DRIVER_NAME" + fi + fi + fi + if [ ! -x "$TAURI_DRIVER_BIN" ]; then + echo "ERROR: $TAURI_DRIVER_NAME not found. Install with: cargo install tauri-driver" >&2 + exit 1 + fi + + echo "Starting tauri-driver on port $TAURI_DRIVER_PORT..." + echo " Driver logs: $DRIVER_LOG" + "$TAURI_DRIVER_BIN" --port "$TAURI_DRIVER_PORT" > "$DRIVER_LOG" 2>&1 & + DRIVER_PID=$! + + for i in $(seq 1 15); do + if curl -sf "http://127.0.0.1:$TAURI_DRIVER_PORT/status" >/dev/null 2>&1; then + echo "tauri-driver is ready." + break + fi + if [ "$i" -eq 15 ]; then + echo "ERROR: tauri-driver did not start within 15 seconds." >&2 + cat "$DRIVER_LOG" >&2 + exit 1 + fi + sleep 1 + done + ;; + Darwin) + # ------------------------------------------------------------------------- + # macOS: start Appium Mac2. + # ------------------------------------------------------------------------- + export APPIUM_PORT="${APPIUM_PORT:-4723}" + # shellcheck source=/dev/null + source "$SCRIPT_DIR/e2e-resolve-node-appium.sh" + + 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 & + DRIVER_PID=$! + + 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 "ERROR: unsupported OS for e2e: $OS" >&2 exit 1 - fi - - echo "Starting tauri-driver on port $TAURI_DRIVER_PORT..." - echo " Driver logs: $DRIVER_LOG" - "$TAURI_DRIVER_BIN" --port "$TAURI_DRIVER_PORT" > "$DRIVER_LOG" 2>&1 & - DRIVER_PID=$! - - for i in $(seq 1 15); do - if curl -sf "http://127.0.0.1:$TAURI_DRIVER_PORT/status" >/dev/null 2>&1; then - echo "tauri-driver is ready." - break - fi - if [ "$i" -eq 15 ]; then - echo "ERROR: tauri-driver did not start within 15 seconds." >&2 - cat "$DRIVER_LOG" >&2 - exit 1 - fi - sleep 1 - done -else - # --------------------------------------------------------------------------- - # macOS: start Appium - # --------------------------------------------------------------------------- - export APPIUM_PORT="${APPIUM_PORT:-4723}" - # shellcheck source=/dev/null - source "$SCRIPT_DIR/e2e-resolve-node-appium.sh" - - 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 & - DRIVER_PID=$! - - 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 -fi + ;; +esac echo "Running E2E spec ($SPEC)..." pnpm exec wdio run test/wdio.conf.ts --spec "$SPEC" diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs index a12f0f06f..df1d1e3b6 100644 --- a/app/src-tauri/src/core_process.rs +++ b/app/src-tauri/src/core_process.rs @@ -188,6 +188,44 @@ impl CoreProcessHandle { // the same env, matching what a child sidecar would have // received via Command::env. std::env::set_var("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str()); + + // Debug-build only: surface the RPC bearer token at a known + // tmpdir path so the e2e test runner (a separate Node process) + // can authenticate against the in-process core. Release builds + // never write this file. The test harness reads it from + // ${tmpdir}/openhuman-e2e-rpc-token. + // + // Token file is owner-read-write only (mode 0600) on Unix so a + // shared dev box doesn't leak the bearer to other local users. + #[cfg(debug_assertions)] + { + use std::io::Write as _; + let token_path = std::env::temp_dir().join("openhuman-e2e-rpc-token"); + let write_result = (|| -> std::io::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.create(true).write(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(&token_path)?; + file.write_all(self.rpc_token.as_bytes())?; + file.sync_all()?; + Ok(()) + })(); + if let Err(err) = write_result { + log::warn!( + "[core] failed to write e2e token file at {}: {err}", + token_path.display() + ); + } else { + log::debug!( + "[core] wrote e2e token file at {} (debug build only)", + token_path.display() + ); + } + } log::info!("[core] spawning embedded in-process core server on port {port}"); let task = tokio::spawn(async move { if let Err(e) = openhuman_core::core::jsonrpc::run_server_embedded( diff --git a/app/test/e2e/helpers/core-rpc-node.ts b/app/test/e2e/helpers/core-rpc-node.ts index 36c61da55..a672ea9f0 100644 --- a/app/test/e2e/helpers/core-rpc-node.ts +++ b/app/test/e2e/helpers/core-rpc-node.ts @@ -1,11 +1,44 @@ /** * Core JSON-RPC from the Node/WebdriverIO process (no WebView `execute`). * Required for Appium Mac2, which does not support W3C Execute Script in WKWebView. + * + * Auth: the in-process core requires a per-launch bearer token that lives only + * inside the Tauri host. For e2e, debug builds of the Tauri shell write that + * token to `${tmpdir}/openhuman-e2e-rpc-token` (see + * `app/src-tauri/src/core_process.rs`). We read it here and attach + * `Authorization: Bearer …` to every probe + call. Release builds never write + * the file, so this code degrades to unauthenticated requests (which the core + * will reject — acceptable since release builds are not the e2e target). */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + import type { RpcCallResult } from './core-rpc-webview'; let cachedRpcUrl: string | null = null; +const E2E_TOKEN_FILENAME = 'openhuman-e2e-rpc-token'; + +function readBearerToken(): string | null { + const tokenPath = path.join(os.tmpdir(), E2E_TOKEN_FILENAME); + try { + const value = fs.readFileSync(tokenPath, 'utf8').trim(); + return value.length > 0 ? value : null; + } catch { + return null; + } +} + +function buildHeaders(includeAuth = true): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + if (includeAuth) { + const token = readBearerToken(); + if (token) headers.Authorization = `Bearer ${token}`; + } + return headers; +} + function normalizeRpcUrl(raw: string): string { const t = raw.trim().replace(/\/$/, ''); return t.endsWith('/rpc') ? t : `${t}/rpc`; @@ -31,11 +64,17 @@ function defaultPortProbeList(): number[] { async function tryPingRpc(url: string): Promise { try { + // Probe without the bearer token: we're iterating candidate ports, and + // any non-core service that happens to be bound to one of them shouldn't + // receive our auth credential as a side effect of discovery. const res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildHeaders(false), body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }), }); + // 401 means "endpoint exists, auth required" — that's a positive match + // for the core RPC URL; the real call will retry with auth attached. + if (res.status === 401) return true; if (!res.ok) return false; const json = (await res.json()) as { error?: { message?: string } }; return !json.error; @@ -86,7 +125,7 @@ export async function callOpenhumanRpcNode( const id = Math.floor(Math.random() * 1e9); const res = await fetch(rpcUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildHeaders(), body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), }); const text = await res.text(); diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts new file mode 100644 index 000000000..1bb73d3bb --- /dev/null +++ b/app/test/e2e/specs/mega-flow.spec.ts @@ -0,0 +1,288 @@ +// @ts-nocheck +/** + * Mega e2e flow — login + Gmail OAuth + Composio triggers in one Mac2 session. + * + * Architecture (per design discussion 2026-05-12): + * - One Appium Mac2 session, one app launch — no per-scenario restarts. + * - Drive the app through: + * 1. Deep links (`openhuman://auth?…`, `openhuman://oauth/success?…`) — + * Mac2 supports these natively via `macos: deepLink`. + * 2. Mock backend behavior knobs and the in-process request log. + * 3. Core JSON-RPC for state inspection and `composio_*` calls. + * - Assertions read from the mock request log and RPC results — never from + * the CEF WebView accessibility tree (which exposes zero DOM to XCUITest). + * - Between scenarios, reset state in-app via `openhuman.config_reset_local_data` + * (mirrors the production "Clear app data + log out" flow) + mock admin reset. + * Then re-write `~/.openhuman/config.toml` so the mock URL persists across + * the reset and the next scenario starts pointing at the mock. + * + * What this covers (the "major user flows" set): + * - Login: deep-link consume → JWT → `/auth/me` fetch + * - Bypass login (deep-link `key=auth`): no consume call but session set + * - Connect Gmail via OAuth deep-link success path + * - OAuth error path is exercised by Scenario 5 + * - Composio: list connections, enable trigger, list triggers, state mutates + * - Factory reset between scenarios (the real product flow) + * + * The smoke spec proved the driver+bundle work; this spec proves the *flows* work. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { waitForApp } from '../helpers/app-helpers'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { triggerDeepLink } from '../helpers/deep-link-helpers'; +import { hasAppChrome } from '../helpers/element-helpers'; +import { + clearRequestLog, + getRequestLog, + resetMockBehavior, + setMockBehavior, + setMockBehaviors, + startMockServer, + stopMockServer, +} from '../mock-server'; + +const LOG = '[MegaFlow]'; +const MOCK_PORT = Number(process.env.E2E_MOCK_PORT || 18473); +const HOME = process.env.HOME || os.homedir(); +const CONFIG_DIR = path.join(HOME, '.openhuman'); +const CONFIG_FILE = path.join(CONFIG_DIR, 'config.toml'); +const MOCK_URL = `http://127.0.0.1:${MOCK_PORT}`; + +function writeMockConfig(): void { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(CONFIG_FILE, `api_url = "${MOCK_URL}"\n`, 'utf8'); +} + +async function waitForMockRequest( + method: string, + urlFragment: string, + timeoutMs = 15_000 +): Promise<{ method: string; url: string } | undefined> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const hit = getRequestLog().find(r => r.method === method && r.url.includes(urlFragment)); + if (hit) return hit; + await browser.pause(400); + } + return undefined; +} + +async function resetEverything(label: string): Promise { + console.log(`${LOG} reset (${label}) — config_reset_local_data + admin reset`); + // 1. Wipe the core's local data — workspace + ~/.openhuman + active marker. + // The active in-process core handles this without a process restart, so + // the session keeps the same RPC port and bearer token. + const reset = await callOpenhumanRpc('openhuman.config_reset_local_data', {}); + if (!reset.ok) { + console.warn(`${LOG} reset RPC failed (non-fatal):`, reset); + } + // 2. Re-write config.toml so the next core startup-path still points at the + // mock backend. config_reset_local_data removed the file. + writeMockConfig(); + // 3. Wipe mock state + request log. + await fetch(`${MOCK_URL}/__admin/reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }).catch(() => {}); + clearRequestLog(); + resetMockBehavior(); + // Settle a beat so any in-flight reactive HTTP calls don't bleed into the + // next scenario's request log. + await browser.pause(800); +} + +describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { + before(async () => { + writeMockConfig(); + await startMockServer(MOCK_PORT); + await waitForApp(); + // On Mac2, the window stays in a not-yet-frontmost state until something + // (a deep link, a click) focuses the app. We assert liveness via the + // menu bar (matches smoke.spec.ts) and let the first scenario's deep + // link bring the window forward. + expect(await hasAppChrome()).toBe(true); + clearRequestLog(); + }); + + after(async () => { + try { + await stopMockServer(); + } catch (err) { + console.log(`${LOG} stopMockServer error (non-fatal):`, err); + } + }); + + // ------------------------------------------------------------------------- + // Sanity — app + driver are alive. The smoke spec covers this elsewhere, + // but we re-assert here so failures downstream have a clean signal. + // ------------------------------------------------------------------------- + it('app is alive', async () => { + expect(await hasAppChrome()).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Scenario 1 — login via real token-consume deep link. + // Expectation: the app POSTs to `/telegram/login-tokens/:t/consume`, gets a + // JWT back from the mock, and follows up with `GET /auth/me`. + // ------------------------------------------------------------------------- + it('login: consume deep link triggers /consume + /auth/me on the mock', async () => { + clearRequestLog(); + setMockBehavior('jwt', 'mega-login-1'); + + await triggerDeepLink('openhuman://auth?token=mega-login-token'); + + const consume = await waitForMockRequest('POST', '/telegram/login-tokens/', 20_000); + expect(consume).toBeDefined(); + console.log(`${LOG} consume hit:`, consume?.url); + + const me = await waitForMockRequest('GET', '/auth/me', 15_000); + expect(me).toBeDefined(); + console.log(`${LOG} /auth/me fetched`); + }); + + // ------------------------------------------------------------------------- + // Scenario 2 — reset state, then login via the bypass deep link + // (`key=auth`). No consume call should be made (the JWT in the URL is the + // session itself), but the app should still fetch the user profile. + // ------------------------------------------------------------------------- + it('bypass login: key=auth deep link skips /consume but still fetches /auth/me', async () => { + await resetEverything('after Scenario 1'); + + // Hand-crafted unsigned JWT — mock /auth/me doesn't validate the signature. + const payload = Buffer.from( + JSON.stringify({ + sub: 'mega-bypass-user', + userId: 'mega-bypass-user', + exp: Math.floor(Date.now() / 1000) + 3600, + }) + ).toString('base64url'); + const bypassJwt = `eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.${payload}.sig`; + + await triggerDeepLink(`openhuman://auth?token=${encodeURIComponent(bypassJwt)}&key=auth`); + + const me = await waitForMockRequest('GET', '/auth/me', 15_000); + expect(me).toBeDefined(); + + const consume = getRequestLog().find( + r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/') + ); + expect(consume).toBeUndefined(); + console.log(`${LOG} bypass: no consume call, /auth/me succeeded`); + }); + + // ------------------------------------------------------------------------- + // Scenario 3 — Gmail OAuth completion via `openhuman://oauth/success`. + // The deep-link handler dispatches a custom 'oauth:success' event and + // navigates to /skills. The app refreshes integration state, which manifests + // as a `GET /auth/integrations` call against the mock. + // ------------------------------------------------------------------------- + it('Gmail OAuth: success deep link refreshes integrations on the backend', async () => { + await resetEverything('after Scenario 2'); + + // Login first — `oauth:success` is only meaningful for an authenticated user. + await triggerDeepLink('openhuman://auth?token=mega-gmail-token'); + await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000); + await waitForMockRequest('GET', '/auth/me', 10_000); + clearRequestLog(); + + await triggerDeepLink('openhuman://oauth/success?integrationId=mock-gmail-int&provider=google'); + + // The handler navigates to /skills and dispatches CustomEvent('oauth:success'). + // Downstream listeners refresh integration state — observable as a fresh + // `/auth/integrations` (and/or `/skills`) call on the mock. + const refresh = + (await waitForMockRequest('GET', '/auth/integrations', 15_000)) || + (await waitForMockRequest('GET', '/skills', 5_000)); + expect(refresh).toBeDefined(); + console.log(`${LOG} oauth:success triggered refresh ${refresh?.url}`); + }); + + // ------------------------------------------------------------------------- + // Scenario 4 — Composio trigger lifecycle via core RPC. Drives the same + // contract the UI uses (composio-triggers-flow.spec.ts) but observes via + // RPC responses + mock log mutation instead of through the WebView. + // ------------------------------------------------------------------------- + it('Composio: enable_trigger via RPC mutates the active-triggers list', async () => { + await resetEverything('after Scenario 3'); + + // Re-login since reset wipes the session. + await triggerDeepLink('openhuman://auth?token=mega-composio-token'); + await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000); + + // Seed connections + available triggers; start with an empty active list. + setMockBehaviors({ + composioConnections: JSON.stringify([{ id: 'c1', toolkit: 'gmail', status: 'ACTIVE' }]), + composioAvailableTriggers: JSON.stringify([ + { slug: 'GMAIL_NEW_GMAIL_MESSAGE', scope: 'static' }, + ]), + composioActiveTriggers: JSON.stringify([]), + }); + + const before = await callOpenhumanRpc('openhuman.composio_list_triggers', {}); + expect(before.ok).toBe(true); + const beforeList = (before.result?.triggers ?? + before.value?.result?.triggers ?? + []) as unknown[]; + expect(Array.isArray(beforeList)).toBe(true); + expect(beforeList).toHaveLength(0); + + const enable = await callOpenhumanRpc('openhuman.composio_enable_trigger', { + connection_id: 'c1', + slug: 'GMAIL_NEW_GMAIL_MESSAGE', + }); + expect(enable.ok).toBe(true); + + const after = await callOpenhumanRpc('openhuman.composio_list_triggers', {}); + expect(after.ok).toBe(true); + const afterList = (after.result?.triggers ?? after.value?.result?.triggers ?? []) as unknown[]; + expect(afterList.length).toBeGreaterThan(0); + console.log(`${LOG} composio: enable mutated active list to`, afterList); + }); + + // ------------------------------------------------------------------------- + // Scenario 5 — OAuth error path. Verifies the app handles the failure + // deep link without crashing the session. + // ------------------------------------------------------------------------- + it('Gmail OAuth: error deep link does not crash the session', async () => { + await resetEverything('after Scenario 4'); + + await triggerDeepLink('openhuman://auth?token=mega-error-token'); + await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000); + clearRequestLog(); + + await triggerDeepLink('openhuman://oauth/error?provider=google&error=access_denied'); + + // Give the handler a moment to emit its error event. + await browser.pause(2_000); + + // Liveness check — the app should still respond to a fresh user fetch. + const post = + (await waitForMockRequest('GET', '/auth/me', 3_000)) || + (await waitForMockRequest('GET', '/auth/integrations', 3_000)); + // It's OK if neither call fires (the error path may not trigger a refresh), + // but the RPC layer must still be alive. + const ping = await callOpenhumanRpc('core.ping', {}); + expect(ping.ok).toBe(true); + console.log(`${LOG} oauth error: core.ping still ok after error deep link`); + if (post) console.log(`${LOG} post-error follow-up:`, post.url); + }); + + // ------------------------------------------------------------------------- + // Scenario 6 — final factory reset. Verifies that after the destructive + // RPC + mock admin reset, a fresh login still works. + // ------------------------------------------------------------------------- + it('post-reset: a fresh login still works end-to-end', async () => { + await resetEverything('final'); + + await triggerDeepLink('openhuman://auth?token=mega-post-reset-token'); + const consume = await waitForMockRequest('POST', '/telegram/login-tokens/', 20_000); + expect(consume).toBeDefined(); + const me = await waitForMockRequest('GET', '/auth/me', 15_000); + expect(me).toBeDefined(); + console.log(`${LOG} post-reset login proves config.toml survives reset`); + }); +}); diff --git a/app/test/wdio.conf.ts b/app/test/wdio.conf.ts index b9e713590..79cdf59b4 100644 --- a/app/test/wdio.conf.ts +++ b/app/test/wdio.conf.ts @@ -57,11 +57,13 @@ function getAppPath(): string { /** * Build capabilities for the current platform. * - * - Linux: tauri-driver (W3C WebDriver, port 4444) - * - macOS: Appium Mac2 (XCUITest, port 4723) + * - Linux: tauri-driver (W3C WebDriver, port 4444) + * - Windows: tauri-driver (W3C WebDriver, port 4444) — same as Linux, + * launches the bare .exe rather than a bundle. + * - macOS: Appium Mac2 (XCUITest, port 4723) */ function getPlatformCapabilities(): Record[] { - if (process.platform === 'linux') { + if (process.platform === 'linux' || process.platform === 'win32') { return [{ 'tauri:options': { application: getAppPath() } }]; } @@ -77,8 +79,8 @@ function getPlatformCapabilities(): Record[] { } /** Port for the automation driver: tauri-driver (4444) or Appium (4723). */ -const isLinuxDriver = process.platform === 'linux'; -const driverPort = isLinuxDriver +const isTauriDriverHost = process.platform === 'linux' || process.platform === 'win32'; +const driverPort = isTauriDriverHost ? parseInt(process.env.TAURI_DRIVER_PORT || '4444', 10) : parseInt(process.env.APPIUM_PORT || '4723', 10); @@ -95,8 +97,8 @@ export const config: Options.Testrunner & Record = { waitforTimeout: 10_000, // Linux tauri-driver can take longer to establish the initial session on // loaded CI runners; keep macOS defaults while giving Linux more headroom. - connectionRetryTimeout: isLinuxDriver ? 240_000 : 120_000, - connectionRetryCount: isLinuxDriver ? 5 : 3, + connectionRetryTimeout: isTauriDriverHost ? 240_000 : 120_000, + connectionRetryCount: isTauriDriverHost ? 5 : 3, // No appium/tauri-driver service — driver is started externally via scripts. framework: 'mocha', reporters: ['spec'], diff --git a/scripts/cancel-stale-pr-ci.mjs b/scripts/cancel-stale-pr-ci.mjs new file mode 100644 index 000000000..276d70b08 --- /dev/null +++ b/scripts/cancel-stale-pr-ci.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; + +const DEFAULT_REPO = 'tinyhumansai/openhuman'; +const DEFAULT_MAX_AGE_MINUTES = 20; +const DEFAULT_OPEN_PR_LIMIT = 200; +const DEFAULT_EXCLUDE_WORKFLOW_PATTERNS = ['release']; +const ACTIVE_RUN_STATUSES = new Set(['queued', 'in_progress', 'pending', 'requested', 'waiting']); + +function printUsage() { + console.log(`Usage: cancel-stale-pr-ci.mjs [options] + +Scan open pull requests, find non-release GitHub Actions runs older than the +age threshold, and cancel them. + +Options: + --repo Repository to inspect (default: ${DEFAULT_REPO}) + --max-age-minutes Cancel runs older than this (default: ${DEFAULT_MAX_AGE_MINUTES}) + --open-pr-limit Max open PRs to inspect (default: ${DEFAULT_OPEN_PR_LIMIT}) + --exclude-workflow Case-insensitive substring/regex fragment to skip. + May be passed multiple times. Default: ${DEFAULT_EXCLUDE_WORKFLOW_PATTERNS.join(', ')} + --execute Actually cancel matching runs. + --help Show this message. + +Examples: + node scripts/cancel-stale-pr-ci.mjs + node scripts/cancel-stale-pr-ci.mjs --execute + node scripts/cancel-stale-pr-ci.mjs --execute --exclude-workflow release --exclude-workflow staging +`); +} + +function fail(message) { + console.error(`[ci-cleanup] ${message}`); + process.exit(1); +} + +function runGhJson(args) { + const stdout = execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return JSON.parse(stdout); +} + +function runGh(args) { + return execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function parseArgs(argv) { + const options = { + repo: DEFAULT_REPO, + maxAgeMinutes: DEFAULT_MAX_AGE_MINUTES, + openPrLimit: DEFAULT_OPEN_PR_LIMIT, + excludeWorkflowPatterns: [...DEFAULT_EXCLUDE_WORKFLOW_PATTERNS], + execute: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--help') { + printUsage(); + process.exit(0); + } + if (arg === '--execute') { + options.execute = true; + continue; + } + if (arg === '--repo') { + options.repo = argv[++i] ?? fail('Missing value for --repo'); + continue; + } + if (arg === '--max-age-minutes') { + options.maxAgeMinutes = Number(argv[++i]); + continue; + } + if (arg === '--open-pr-limit') { + options.openPrLimit = Number(argv[++i]); + continue; + } + if (arg === '--exclude-workflow') { + const pattern = argv[++i]; + if (!pattern) { + fail('Missing value for --exclude-workflow'); + } + options.excludeWorkflowPatterns.push(pattern); + continue; + } + fail(`Unknown argument: ${arg}`); + } + + if (!Number.isFinite(options.maxAgeMinutes) || options.maxAgeMinutes <= 0) { + fail('--max-age-minutes must be a positive number'); + } + if (!Number.isInteger(options.openPrLimit) || options.openPrLimit <= 0) { + fail('--open-pr-limit must be a positive integer'); + } + + return options; +} + +function buildExcludeRegexes(patterns) { + return patterns.map((pattern) => new RegExp(pattern, 'i')); +} + +function matchesExcludedWorkflow(run, excludeRegexes) { + const haystacks = [run.name ?? '', run.path ?? '', run.display_title ?? '']; + return excludeRegexes.some((regex) => haystacks.some((value) => regex.test(value))); +} + +function formatMinutes(minutes) { + return `${minutes.toFixed(1)}m`; +} + +function getOpenPullRequests(repo, limit) { + return runGhJson([ + 'pr', + 'list', + '--repo', + repo, + '--state', + 'open', + '--limit', + String(limit), + '--json', + 'number,title,headRefName,headRefOid,url', + ]); +} + +function getWorkflowRuns(repo, status) { + const response = runGhJson([ + 'api', + `repos/${repo}/actions/runs?event=pull_request&status=${encodeURIComponent(status)}&per_page=100`, + ]); + return response.workflow_runs ?? []; +} + +function getRunJobs(repo, runId) { + const response = runGhJson([ + 'api', + `repos/${repo}/actions/runs/${runId}/jobs?per_page=100`, + ]); + return response.jobs ?? []; +} + +function cancelRun(repo, runId) { + try { + runGh(['api', '--method', 'POST', `repos/${repo}/actions/runs/${runId}/force-cancel`]); + return 'force-cancel'; + } catch { + runGh(['api', '--method', 'POST', `repos/${repo}/actions/runs/${runId}/cancel`]); + return 'cancel'; + } +} + +function summarizeActiveJobs(jobs) { + return jobs + .filter((job) => ACTIVE_RUN_STATUSES.has(job.status)) + .map((job) => job.name) + .sort(); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const excludeRegexes = buildExcludeRegexes(options.excludeWorkflowPatterns); + const now = Date.now(); + + console.log( + `[ci-cleanup] repo=${options.repo} mode=${options.execute ? 'execute' : 'dry-run'} maxAge=${options.maxAgeMinutes}m`, + ); + console.log( + `[ci-cleanup] excluding workflows matching: ${options.excludeWorkflowPatterns.join(', ')}`, + ); + + const prs = getOpenPullRequests(options.repo, options.openPrLimit); + const prsByHeadSha = new Map(prs.map((pr) => [pr.headRefOid, pr])); + const seenRunIds = new Set(); + const candidates = []; + + for (const status of ACTIVE_RUN_STATUSES) { + const runs = getWorkflowRuns(options.repo, status); + for (const run of runs) { + if (seenRunIds.has(run.id)) { + continue; + } + seenRunIds.add(run.id); + + const pr = prsByHeadSha.get(run.head_sha); + if (!pr) { + continue; + } + + if (!ACTIVE_RUN_STATUSES.has(run.status)) { + continue; + } + if (matchesExcludedWorkflow(run, excludeRegexes)) { + continue; + } + + const startedAt = run.run_started_at ?? run.created_at; + if (!startedAt) { + continue; + } + const ageMinutes = (now - Date.parse(startedAt)) / 60000; + if (!Number.isFinite(ageMinutes) || ageMinutes < options.maxAgeMinutes) { + continue; + } + + const jobs = getRunJobs(options.repo, run.id); + const activeJobs = summarizeActiveJobs(jobs); + + candidates.push({ + pr, + run, + ageMinutes, + activeJobs, + }); + } + } + + if (candidates.length === 0) { + console.log('[ci-cleanup] no stale PR workflow runs matched'); + return; + } + + candidates.sort((a, b) => b.ageMinutes - a.ageMinutes); + + for (const candidate of candidates) { + const { pr, run, ageMinutes, activeJobs } = candidate; + const jobsLabel = activeJobs.length > 0 ? activeJobs.join(', ') : 'unknown active jobs'; + console.log( + `[ci-cleanup] candidate pr=#${pr.number} run=${run.id} workflow="${run.name}" status=${run.status} age=${formatMinutes(ageMinutes)} jobs=[${jobsLabel}] url=${run.html_url}`, + ); + } + + if (!options.execute) { + console.log('[ci-cleanup] dry-run only; re-run with --execute to cancel these runs'); + return; + } + + let cancelled = 0; + for (const candidate of candidates) { + const method = cancelRun(options.repo, candidate.run.id); + cancelled += 1; + console.log( + `[ci-cleanup] cancelled pr=#${candidate.pr.number} run=${candidate.run.id} workflow="${candidate.run.name}" via ${method}`, + ); + } + + console.log(`[ci-cleanup] cancelled ${cancelled} stale workflow run(s)`); +} + +main(); diff --git a/scripts/merge-main-into-open-prs.mjs b/scripts/merge-main-into-open-prs.mjs new file mode 100644 index 000000000..0298ed3eb --- /dev/null +++ b/scripts/merge-main-into-open-prs.mjs @@ -0,0 +1,382 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const DEFAULT_REPO = 'tinyhumansai/openhuman'; +const DEFAULT_BASE_BRANCH = 'main'; +const DEFAULT_OPEN_PR_LIMIT = 200; + +function printUsage() { + console.log(`Usage: merge-main-into-open-prs.mjs [options] + +Scan open pull requests, check out each head branch in a temporary clone, merge +the latest base branch into it, and push the result back to the same PR branch. +PRs with merge conflicts or push failures are skipped. + +Options: + --repo Repository to inspect (default: ${DEFAULT_REPO}) + --base-branch Base branch to merge into PRs (default: ${DEFAULT_BASE_BRANCH}) + --open-pr-limit Max open PRs to inspect (default: ${DEFAULT_OPEN_PR_LIMIT}) + --pr Restrict to one PR number. May be passed multiple times. + --include-drafts Include draft PRs (default: false) + --execute Actually merge and push. Dry-run by default. + --help Show this message. + +Examples: + node scripts/merge-main-into-open-prs.mjs + node scripts/merge-main-into-open-prs.mjs --execute + node scripts/merge-main-into-open-prs.mjs --execute --pr 101 --pr 205 + node scripts/merge-main-into-open-prs.mjs --execute --include-drafts +`); +} + +function fail(message) { + console.error(`[pr-main-sync] ${message}`); + process.exit(1); +} + +function run(command, args, options = {}) { + const { cwd, stdio = ['ignore', 'pipe', 'pipe'] } = options; + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + stdio, + }); +} + +function runJson(command, args, options = {}) { + return JSON.parse(run(command, args, options)); +} + +function tryRun(command, args, options = {}) { + try { + return { + ok: true, + stdout: run(command, args, options), + }; + } catch (error) { + return { + ok: false, + error, + stdout: error.stdout?.toString?.() ?? '', + stderr: error.stderr?.toString?.() ?? '', + }; + } +} + +function parseArgs(argv) { + const options = { + repo: DEFAULT_REPO, + baseBranch: DEFAULT_BASE_BRANCH, + openPrLimit: DEFAULT_OPEN_PR_LIMIT, + prs: [], + includeDrafts: false, + execute: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--help') { + printUsage(); + process.exit(0); + } + if (arg === '--execute') { + options.execute = true; + continue; + } + if (arg === '--include-drafts') { + options.includeDrafts = true; + continue; + } + if (arg === '--repo') { + options.repo = argv[++i] ?? fail('Missing value for --repo'); + continue; + } + if (arg === '--base-branch') { + options.baseBranch = argv[++i] ?? fail('Missing value for --base-branch'); + continue; + } + if (arg === '--open-pr-limit') { + options.openPrLimit = Number(argv[++i]); + continue; + } + if (arg === '--pr') { + const value = argv[++i]; + if (!value) { + fail('Missing value for --pr'); + } + const pr = Number(value); + if (!Number.isInteger(pr) || pr <= 0) { + fail(`Invalid PR number for --pr: ${value}`); + } + options.prs.push(pr); + continue; + } + fail(`Unknown argument: ${arg}`); + } + + if (!Number.isInteger(options.openPrLimit) || options.openPrLimit <= 0) { + fail('--open-pr-limit must be a positive integer'); + } + + return options; +} + +function getOpenPullRequests(repo, limit) { + return runJson('gh', [ + 'pr', + 'list', + '--repo', + repo, + '--state', + 'open', + '--limit', + String(limit), + '--json', + 'number,title,url,isDraft,baseRefName,headRefName,headRepository,headRepositoryOwner', + ]); +} + +function getPreferredRemoteUrls(repoRoot) { + const output = run('git', ['remote', '-v'], { cwd: repoRoot }); + const map = new Map(); + + for (const line of output.split('\n')) { + const match = line.match(/^\S+\s+(\S+)\s+\((fetch|push)\)$/); + if (!match) { + continue; + } + const url = match[1]; + const repo = normalizeRepoFromRemoteUrl(url); + if (!repo || map.has(repo)) { + continue; + } + map.set(repo, url); + } + + return map; +} + +function normalizeRepoFromRemoteUrl(url) { + return url + .replace(/^git@github\.com:/, '') + .replace(/^https?:\/\/github\.com\//, '') + .replace(/\.git$/, ''); +} + +function sanitizeBranchName(branch) { + return branch.replace(/[^A-Za-z0-9._/-]/g, '-'); +} + +function ensureCleanBranch(cloneDir, branchName, remoteName, remoteBranch) { + const localBranch = sanitizeBranchName(branchName); + run('git', ['checkout', '-B', localBranch, `${remoteName}/${remoteBranch}`], { cwd: cloneDir }); + run('git', ['reset', '--hard', `${remoteName}/${remoteBranch}`], { cwd: cloneDir }); + run('git', ['clean', '-fd'], { cwd: cloneDir }); +} + +function setupTempClone(repo, baseBranch) { + const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openhuman-pr-main-sync-')); + const repoUrl = `git@github.com:${repo}.git`; + + run('git', ['init'], { cwd: cloneDir }); + run('git', ['remote', 'add', 'upstream', repoUrl], { cwd: cloneDir }); + run('git', ['fetch', '--depth', '50', 'upstream', baseBranch], { cwd: cloneDir }); + run('git', ['checkout', '-B', baseBranch, 'FETCH_HEAD'], { cwd: cloneDir }); + + return cloneDir; +} + +function getHeadRepoSlug(pr) { + const owner = pr.headRepositoryOwner?.login; + const name = pr.headRepository?.name; + if (!owner || !name) { + return null; + } + return `${owner}/${name}`; +} + +function mergeBaseIntoPr({ cloneDir, pr, baseBranch, preferredRemoteUrls, execute }) { + const headRepo = getHeadRepoSlug(pr); + if (!headRepo) { + return { + status: 'skipped', + reason: 'missing-head-repo', + }; + } + + const remoteName = `pr-${pr.number}`; + const remoteUrl = preferredRemoteUrls.get(headRepo) ?? `git@github.com:${headRepo}.git`; + const headBranch = pr.headRefName; + const localBranch = `pr/${pr.number}`; + + const addRemoteResult = tryRun('git', ['remote', 'add', remoteName, remoteUrl], { cwd: cloneDir }); + if (!addRemoteResult.ok && !addRemoteResult.stderr.includes('already exists')) { + return { + status: 'skipped', + reason: 'remote-add-failed', + detail: addRemoteResult.stderr.trim(), + }; + } + + run('git', ['fetch', '--depth', '50', 'upstream', baseBranch], { cwd: cloneDir }); + + const fetchResult = tryRun( + 'git', + ['fetch', remoteName, `+refs/heads/${headBranch}:refs/remotes/${remoteName}/${headBranch}`], + { cwd: cloneDir }, + ); + if (!fetchResult.ok) { + return { + status: 'skipped', + reason: 'head-fetch-failed', + detail: fetchResult.stderr.trim(), + }; + } + + ensureCleanBranch(cloneDir, localBranch, remoteName, headBranch); + + const mergeResult = tryRun( + 'git', + ['merge', '--no-ff', '--no-edit', `upstream/${baseBranch}`], + { cwd: cloneDir }, + ); + + if (!mergeResult.ok) { + tryRun('git', ['merge', '--abort'], { cwd: cloneDir }); + return { + status: 'skipped', + reason: 'merge-conflict', + detail: mergeResult.stderr.trim() || mergeResult.stdout.trim(), + }; + } + + const newHead = run('git', ['rev-parse', 'HEAD'], { cwd: cloneDir }).trim(); + const remoteHead = run('git', ['rev-parse', `${remoteName}/${headBranch}`], { cwd: cloneDir }).trim(); + const changed = newHead !== remoteHead; + + if (!changed) { + return { + status: 'noop', + reason: 'already-up-to-date', + }; + } + + if (!execute) { + return { + status: 'dry-run', + reason: 'would-push', + }; + } + + const pushResult = tryRun('git', ['push', remoteName, `HEAD:${headBranch}`], { cwd: cloneDir }); + if (!pushResult.ok) { + return { + status: 'skipped', + reason: 'push-failed', + detail: pushResult.stderr.trim() || pushResult.stdout.trim(), + }; + } + + return { + status: 'pushed', + reason: 'merged-and-pushed', + }; +} + +function filterPullRequests(prs, options) { + const targetPrs = options.prs.length > 0 ? new Set(options.prs) : null; + + return prs.filter((pr) => { + if (pr.baseRefName !== options.baseBranch) { + return false; + } + if (!options.includeDrafts && pr.isDraft) { + return false; + } + if (targetPrs && !targetPrs.has(pr.number)) { + return false; + } + return true; + }); +} + +function summarizeResult(result) { + if (!result.detail) { + return result.reason; + } + return `${result.reason}: ${result.detail}`; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const repoRoot = process.cwd(); + const preferredRemoteUrls = getPreferredRemoteUrls(repoRoot); + + console.log( + `[pr-main-sync] repo=${options.repo} base=${options.baseBranch} mode=${options.execute ? 'execute' : 'dry-run'} limit=${options.openPrLimit}`, + ); + if (options.prs.length > 0) { + console.log(`[pr-main-sync] restricted to PRs: ${options.prs.join(', ')}`); + } + + const prs = filterPullRequests(getOpenPullRequests(options.repo, options.openPrLimit), options); + if (prs.length === 0) { + console.log('[pr-main-sync] no matching open PRs found'); + return; + } + + console.log(`[pr-main-sync] matched ${prs.length} PR(s)`); + + const cloneDir = setupTempClone(options.repo, options.baseBranch); + const results = []; + + try { + for (const pr of prs) { + console.log( + `[pr-main-sync] processing pr=#${pr.number} branch=${pr.headRefName} url=${pr.url}`, + ); + const result = mergeBaseIntoPr({ + cloneDir, + pr, + baseBranch: options.baseBranch, + preferredRemoteUrls, + execute: options.execute, + }); + results.push({ pr, ...result }); + console.log( + `[pr-main-sync] result pr=#${pr.number} status=${result.status} ${summarizeResult(result)}`, + ); + } + } finally { + fs.rmSync(cloneDir, { recursive: true, force: true }); + } + + const counts = { + pushed: 0, + dryRun: 0, + noop: 0, + skipped: 0, + }; + + for (const result of results) { + if (result.status === 'pushed') { + counts.pushed += 1; + } else if (result.status === 'dry-run') { + counts.dryRun += 1; + } else if (result.status === 'noop') { + counts.noop += 1; + } else { + counts.skipped += 1; + } + } + + console.log( + `[pr-main-sync] summary pushed=${counts.pushed} dry-run=${counts.dryRun} noop=${counts.noop} skipped=${counts.skipped}`, + ); +} + +main(); diff --git a/scripts/mock-api-core.mjs b/scripts/mock-api-core.mjs index d54532333..900621b1e 100644 --- a/scripts/mock-api-core.mjs +++ b/scripts/mock-api-core.mjs @@ -1,1514 +1,19 @@ -import crypto from "node:crypto"; -import http from "node:http"; - -const DEFAULT_PORT = 18473; -const MOCK_JWT = "e2e-mock-jwt-token"; -const MAX_PORT_RETRY_ATTEMPTS = 10; - -let requestLog = []; -let mockBehavior = {}; -let server = null; -const openSockets = new Set(); -let mockTunnels = []; - -const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": - "Content-Type, Authorization, x-device-fingerprint, x-tauri-version, x-core-version, x-ios-version, x-android-version, x-web-version", - "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, - remainingUsd: 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 resetMockTunnels() { - mockTunnels = []; -} - -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; - } -} - -const REDACTED_HEADER_VALUE = "[REDACTED]"; -const SENSITIVE_HEADER_NAMES = new Set([ - "authorization", - "cookie", - "set-cookie", - "proxy-authorization", -]); - -function normalizeHeaders(headers) { - const entries = Object.entries(headers || {}); - return Object.fromEntries( - entries.map(([key, value]) => { - if (SENSITIVE_HEADER_NAMES.has(String(key).toLowerCase())) { - return [key, REDACTED_HEADER_VALUE]; - } - return [ - key, - Array.isArray(value) ? value.join(", ") : String(value ?? ""), - ]; - }), - ); -} - -function getDelayMs(key) { - const value = Number(mockBehavior[key] || 0); - return Number.isFinite(value) && value > 0 ? value : 0; -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function createMockTunnel(payload = {}) { - const now = new Date().toISOString(); - return { - id: crypto.randomUUID(), - uuid: crypto.randomUUID(), - name: String(payload.name || "Mock Tunnel").trim(), - description: String(payload.description || "").trim(), - isActive: payload.isActive ?? true, - createdAt: now, - updatedAt: now, - }; -} - -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, - headers: normalizeHeaders(req.headers), - 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(); - resetMockTunnels(); - 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) || - /^\/auth\/me\/?(\?.*)?$/.test(url)) - ) { - const delayMs = getDelayMs("telegramMeDelayMs"); - if (delayMs > 0) { - await sleep(delayMs); - } - 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" && /^\/teams\/me\/usage\/?(\?.*)?$/.test(url)) { - json(res, 200, { - success: true, - data: { - cycleBudgetUsd: 10, - remainingUsd: 10, - cycleLimit5hr: 0, - cycleLimit7day: 0, - fiveHourCapUsd: 5, - fiveHourResetsAt: null, - cycleStartDate: new Date().toISOString(), - cycleEndsAt: new Date( - Date.now() + 7 * 24 * 60 * 60 * 1000, - ).toISOString(), - bypassCycleLimit: false, - }, - }); - return; - } - - if (method === "GET" && /^\/auth\/integrations\/?(\?.*)?$/.test(url)) { - json(res, 200, { success: true, data: [] }); - return; - } - - if (method === "POST" && /^\/webhooks\/core\/?$/.test(url)) { - const tunnel = createMockTunnel(parsedBody || {}); - mockTunnels.unshift(tunnel); - json(res, 200, { success: true, data: tunnel }); - return; - } - - if (method === "GET" && /^\/webhooks\/core\/?(\?.*)?$/.test(url)) { - json(res, 200, { success: true, data: mockTunnels }); - return; - } - - if (method === "GET" && /^\/webhooks\/core\/bandwidth\/?(\?.*)?$/.test(url)) { - json(res, 200, { success: true, data: { remainingBudgetUsd: 10 } }); - return; - } - - const webhookCoreMatch = url.match(/^\/webhooks\/core\/([^/?]+)\/?(\?.*)?$/); - if (webhookCoreMatch) { - const [, tunnelId] = webhookCoreMatch; - const tunnelIndex = mockTunnels.findIndex((entry) => entry.id === tunnelId); - const tunnel = tunnelIndex >= 0 ? mockTunnels[tunnelIndex] : null; - - if (!tunnel) { - json(res, 404, { success: false, error: "Tunnel not found" }); - return; - } - - if (method === "GET") { - json(res, 200, { success: true, data: tunnel }); - return; - } - - if (method === "PATCH") { - const updated = { - ...tunnel, - ...(parsedBody || {}), - updatedAt: new Date().toISOString(), - }; - mockTunnels[tunnelIndex] = updated; - json(res, 200, { success: true, data: updated }); - return; - } - - if (method === "DELETE") { - mockTunnels.splice(tunnelIndex, 1); - json(res, 200, { success: true, data: tunnel }); - return; - } - } - - // --- Payments / Credits / Billing --- - - if ( - method === "GET" && - /^\/payments\/credits\/balance\/?(\?.*)?$/.test(url) - ) { - json(res, 200, { - success: true, - data: { balanceUsd: 10, topUpBalanceUsd: 0, topUpBaselineUsd: 0 }, - }); - return; - } - - if ( - method === "GET" && - (/^\/payments\/plan\/?(\?.*)?$/.test(url) || - /^\/payments\/stripe\/currentPlan\/?(\?.*)?$/.test(url)) - ) { - const plan = mockBehavior.plan || "FREE"; - const isActive = mockBehavior.planActive === "true"; - const periodEnd = new Date(Date.now() + 30 * 86400000).toISOString(); - json(res, 200, { - success: true, - data: { - plan, - hasActiveSubscription: isActive, - planExpiry: isActive ? periodEnd : null, - subscription: isActive - ? { id: "sub_mock_1", status: "active", currentPeriodEnd: periodEnd } - : null, - }, - }); - return; - } - - if ( - method === "POST" && - (/^\/payments\/stripe\/checkout\/?$/.test(url) || - /^\/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: { - sessionId: "cs_mock_" + Date.now(), - // Return null checkoutUrl so the app doesn't navigate the WebView away. - // The test verifies the API call was made, not the redirect. - checkoutUrl: null, - }, - }); - return; - } - - if (method === "POST" && /^\/payments\/stripe\/portal\/?$/.test(url)) { - json(res, 200, { - success: true, - data: { portalUrl: "https://billing.stripe.com/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; - } - json(res, 200, { - success: true, - data: { - gatewayTransactionId: "charge_mock_" + Date.now(), - hostedUrl: "https://commerce.coinbase.com/mock-charge", - status: "NEW", - expiresAt: new Date(Date.now() + 3600000).toISOString(), - }, - }); - return; - } - - if (method === "POST" && /^\/payments\/purchase\/?$/.test(url)) { - const plan = parsedBody?.plan || mockBehavior.plan || "BASIC"; - json(res, 200, { - success: true, - data: { - sessionId: "cs_mock_" + Date.now(), - url: "https://checkout.stripe.com/mock-purchase", - plan, - }, - }); - return; - } - - if ( - method === "GET" && - /^\/payments\/credits\/auto-recharge\/?(\?.*)?$/.test(url) - ) { - json(res, 200, { - success: true, - data: { - enabled: false, - thresholdUsd: 5, - rechargeAmountUsd: 10, - weeklyLimitUsd: 50, - spentThisWeekUsd: 0, - weekStartDate: new Date().toISOString(), - inFlight: false, - hasSavedPaymentMethod: false, - lastTriggeredAt: null, - lastRechargeAt: null, - }, - }); - return; - } - - if (method === "GET" && /^\/payments\/cards\/?(\?.*)?$/.test(url)) { - json(res, 200, { success: true, data: { cards: [], defaultCardId: null } }); - return; - } - - if ( - method === "GET" && - /^\/payments\/credits\/auto-recharge\/cards\/?(\?.*)?$/.test(url) - ) { - json(res, 200, { success: true, data: { cards: [], defaultCardId: null } }); - 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 === "GET" && /^\/referral\/stats\/?(\?.*)?$/.test(url)) { - const origin = requestOrigin(req); - json(res, 200, { - success: true, - data: { - referralCode: "MOCKREF1", - referralLink: `${origin}/#/rewards?ref=MOCKREF1`, - totals: { - totalRewardUsd: 10, - pendingCount: 1, - convertedCount: 2, - }, - referrals: [ - { - id: "ref-row-1", - referredUserId: "user-456", - status: "pending", - createdAt: new Date(Date.now() - 86400000).toISOString(), - }, - { - id: "ref-row-2", - referredUserId: "user-789", - status: "converted", - createdAt: new Date(Date.now() - 172800000).toISOString(), - convertedAt: new Date().toISOString(), - rewardUsd: 5, - }, - ], - appliedReferralCode: null, - canApplyReferral: true, - }, - }); - return; - } - - if (method === "POST" && /^\/referral\/claim\/?$/.test(url)) { - json(res, 200, { - success: true, - data: { ok: true, message: "Referral claimed" }, - }); - return; - } - - // Rewards & Progression snapshot — feature 12.x. - // - // Honours mockBehavior knobs so individual e2e cases can flip unlock state - // without rewriting fixtures: - // - // rewardsScenario — preset bundle: - // "default" (FREE plan, no streak, no Discord) - // "activity_unlocked" (12.1.1 — streak/feature counts trigger achievement) - // "integration_unlocked" (12.1.2 — Discord member assigns role) - // "plan_unlocked" (12.1.3 — PRO plan unlocks tier achievement) - // "high_usage" (12.2.1/12.2.2 — message + token + streak metrics) - // "post_restart" (12.2.3 — same metrics persist after the second fetch) - // rewardsServiceError — when "true", returns 503 to exercise the failure path. - // rewardsLastSyncedAt — overrides the metrics.lastSyncedAt timestamp (useful for restart drift assertions). - if (method === "GET" && /^\/rewards\/me\/?(\?.*)?$/.test(url)) { - if (mockBehavior.rewardsServiceError === "true") { - json(res, 503, { - success: false, - error: "Rewards service unavailable", - }); - return; - } - - const scenario = mockBehavior.rewardsScenario || "default"; - const lastSyncedAt = - mockBehavior.rewardsLastSyncedAt || new Date().toISOString(); - - const baseAchievements = [ - { - id: "STREAK_7", - title: "7-Day Streak", - description: "Use OpenHuman on seven consecutive active days.", - actionLabel: "Keep your streak alive for 7 days", - unlocked: false, - progressLabel: "0 / 7 days", - roleId: "role-streak-7", - discordRoleStatus: "not_linked", - creditAmountUsd: null, - }, - { - id: "DISCORD_MEMBER", - title: "Discord Member", - description: "Join the OpenHuman Discord server.", - actionLabel: "Connect Discord and join the server", - unlocked: false, - progressLabel: "Not joined", - roleId: "role-discord-member", - discordRoleStatus: "not_linked", - creditAmountUsd: null, - }, - { - id: "PLAN_PRO", - title: "Pro Supporter", - description: "Upgrade to the Pro plan.", - actionLabel: "Upgrade to Pro", - unlocked: false, - progressLabel: "Locked", - roleId: "role-plan-pro", - discordRoleStatus: "not_assigned", - creditAmountUsd: 5, - }, - ]; - - let snapshot; - switch (scenario) { - case "activity_unlocked": - snapshot = { - discord: { - linked: false, - discordId: null, - inviteUrl: "https://discord.gg/openhuman", - membershipStatus: "not_linked", - }, - summary: { - unlockedCount: 1, - totalCount: 3, - assignedDiscordRoleCount: 0, - plan: "FREE", - hasActiveSubscription: false, - }, - metrics: { - currentStreakDays: 7, - longestStreakDays: 7, - cumulativeTokens: 250000, - featuresUsedCount: 4, - trackedFeaturesCount: 6, - lastEvaluatedAt: lastSyncedAt, - lastSyncedAt, - }, - achievements: [ - { - ...baseAchievements[0], - unlocked: true, - progressLabel: "Unlocked", - discordRoleStatus: "not_linked", - }, - baseAchievements[1], - baseAchievements[2], - ], - }; - break; - case "integration_unlocked": - snapshot = { - discord: { - linked: true, - discordId: "discord-mock-123", - inviteUrl: "https://discord.gg/openhuman", - membershipStatus: "member", - }, - summary: { - unlockedCount: 1, - totalCount: 3, - assignedDiscordRoleCount: 1, - plan: "FREE", - hasActiveSubscription: false, - }, - metrics: { - currentStreakDays: 0, - longestStreakDays: 0, - cumulativeTokens: 0, - featuresUsedCount: 0, - trackedFeaturesCount: 6, - lastEvaluatedAt: lastSyncedAt, - lastSyncedAt, - }, - achievements: [ - baseAchievements[0], - { - ...baseAchievements[1], - unlocked: true, - progressLabel: "Unlocked", - discordRoleStatus: "assigned", - }, - baseAchievements[2], - ], - }; - break; - case "plan_unlocked": - snapshot = { - discord: { - linked: false, - discordId: null, - inviteUrl: "https://discord.gg/openhuman", - membershipStatus: "not_linked", - }, - summary: { - unlockedCount: 1, - totalCount: 3, - assignedDiscordRoleCount: 0, - plan: "PRO", - hasActiveSubscription: true, - }, - metrics: { - currentStreakDays: 0, - longestStreakDays: 0, - cumulativeTokens: 0, - featuresUsedCount: 0, - trackedFeaturesCount: 6, - lastEvaluatedAt: lastSyncedAt, - lastSyncedAt, - }, - achievements: [ - baseAchievements[0], - baseAchievements[1], - { - ...baseAchievements[2], - unlocked: true, - progressLabel: "Unlocked", - discordRoleStatus: "not_linked", - }, - ], - }; - break; - case "high_usage": - case "post_restart": - snapshot = { - discord: { - linked: true, - discordId: "discord-mock-123", - inviteUrl: "https://discord.gg/openhuman", - membershipStatus: "member", - }, - summary: { - unlockedCount: 3, - totalCount: 3, - assignedDiscordRoleCount: 1, - plan: "PRO", - hasActiveSubscription: true, - }, - metrics: { - currentStreakDays: 14, - longestStreakDays: 21, - cumulativeTokens: 12500000, - featuresUsedCount: 6, - trackedFeaturesCount: 6, - lastEvaluatedAt: lastSyncedAt, - lastSyncedAt, - }, - achievements: [ - { - ...baseAchievements[0], - unlocked: true, - progressLabel: "Unlocked", - discordRoleStatus: "assigned", - }, - { - ...baseAchievements[1], - unlocked: true, - progressLabel: "Unlocked", - discordRoleStatus: "assigned", - }, - { - ...baseAchievements[2], - unlocked: true, - progressLabel: "Unlocked", - discordRoleStatus: "assigned", - }, - ], - }; - break; - case "default": - default: - snapshot = { - discord: { - linked: false, - discordId: null, - inviteUrl: "https://discord.gg/openhuman", - membershipStatus: "not_linked", - }, - summary: { - unlockedCount: 0, - totalCount: 3, - assignedDiscordRoleCount: 0, - plan: "FREE", - hasActiveSubscription: false, - }, - metrics: { - currentStreakDays: 0, - longestStreakDays: 0, - cumulativeTokens: 0, - featuresUsedCount: 0, - trackedFeaturesCount: 6, - lastEvaluatedAt: lastSyncedAt, - lastSyncedAt, - }, - achievements: baseAchievements, - }; - break; - } - - json(res, 200, { success: true, data: snapshot }); - return; - } - - if ( - method === "POST" && - /^\/telegram\/settings\/onboarding-complete\/?$/.test(url) - ) { - json(res, 200, { success: true, data: {} }); - return; - } - if (method === "POST" && /^\/settings\/onboarding-complete\/?$/.test(url)) { - json(res, 200, { success: true, data: {} }); - return; - } - - // currentPlan is handled by the earlier consolidated handler. - if (method === "GET" && /^\/billing\/current-plan\/?(\?.*)?$/.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; - } - - // purchasePlan, portal, and coinbase/charge are handled by the earlier - // consolidated handlers (with mockBehavior checks). Only the coinbase - // charge-status polling endpoint remains here. - - 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, "

Mock OAuth

"); - return; - } - if (method === "GET" && /^\/mock-oauth\/?(\?.*)?$/.test(url)) { - html(res, 200, "

Mock OAuth Redirect

"); - return; - } - - // ── Composio routes (used by trigger-toggles E2E spec) ──────────── - // - // Behavior knobs (set via /__admin/behavior): - // composioConnections — JSON array, default `[{id:'c1',toolkit:'gmail',status:'ACTIVE'}]` - // composioAvailableTriggers — JSON array, default one Gmail trigger - // composioActiveTriggers — JSON array, default empty - // composioEnableFails — '1' to make POST /triggers return 500 - // - // Enable/disable requests mutate `composioActiveTriggers` in place so the - // UI can poll subsequent reads and observe the change. - if ( - method === "GET" && - /^\/agent-integrations\/composio\/connections\/?(\?.*)?$/.test(url) - ) { - const connections = parseBehaviorJson("composioConnections", [ - { id: "c1", toolkit: "gmail", status: "ACTIVE" }, - ]); - json(res, 200, { success: true, data: { connections } }); - return; - } - - if ( - method === "GET" && - /^\/agent-integrations\/composio\/triggers\/available(\?.*)?$/.test(url) - ) { - const triggers = parseBehaviorJson("composioAvailableTriggers", [ - { slug: "GMAIL_NEW_GMAIL_MESSAGE", scope: "static" }, - ]); - json(res, 200, { success: true, data: { triggers } }); - return; - } - - if ( - method === "GET" && - /^\/agent-integrations\/composio\/triggers(\?.*)?$/.test(url) - ) { - const triggers = parseBehaviorJson("composioActiveTriggers", []); - json(res, 200, { success: true, data: { triggers } }); - return; - } - - if ( - method === "POST" && - /^\/agent-integrations\/composio\/triggers\/?$/.test(url) - ) { - if (mockBehavior.composioEnableFails === "1") { - json(res, 500, { success: false, error: "Mock enable trigger failure" }); - return; - } - const slug = - typeof parsedBody?.slug === "string" ? parsedBody.slug.trim() : ""; - const connectionId = - typeof parsedBody?.connectionId === "string" - ? parsedBody.connectionId.trim() - : ""; - if (!slug) { - json(res, 400, { success: false, error: "Missing required field: slug" }); - return; - } - if (!connectionId) { - json(res, 400, { - success: false, - error: "Missing required field: connectionId", - }); - return; - } - const triggerId = `ti-${Date.now()}`; - const active = parseBehaviorJson("composioActiveTriggers", []); - active.push({ - id: triggerId, - slug, - toolkit: slug.split("_")[0]?.toLowerCase() ?? "", - connectionId, - ...(parsedBody?.triggerConfig - ? { triggerConfig: parsedBody.triggerConfig } - : {}), - }); - setMockBehavior("composioActiveTriggers", JSON.stringify(active)); - json(res, 200, { - success: true, - data: { triggerId, slug, connectionId }, - }); - return; - } - - if ( - method === "DELETE" && - /^\/agent-integrations\/composio\/triggers\/[^/]+\/?$/.test(url) - ) { - let id = url.split("/").filter(Boolean).pop() ?? ""; - id = id.split("?")[0]; - if (!id) { - json(res, 400, { success: false, error: "Missing trigger id" }); - return; - } - try { - id = decodeURIComponent(id); - } catch { - json(res, 400, { success: false, error: "Invalid trigger id encoding" }); - return; - } - const active = parseBehaviorJson("composioActiveTriggers", []); - const next = active.filter((t) => t.id !== id); - const deleted = next.length !== active.length; - if (deleted) { - setMockBehavior("composioActiveTriggers", JSON.stringify(next)); - } - json(res, 200, { success: true, data: { deleted } }); - return; - } - - // Catch-all: fail fast so tests notice missing mock endpoints. - console.log(`[MockServer] UNHANDLED ${method} ${url}`); - json(res, 404, { - success: false, - error: `Mock server: no handler for ${method} ${url}`, - }); -} - -function parseBehaviorJson(key, fallback) { - const raw = mockBehavior[key]; - if (!raw) return JSON.parse(JSON.stringify(fallback)); - try { - return JSON.parse(raw); - } catch { - return JSON.parse(JSON.stringify(fallback)); - } -} - -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 getMockServerPort() { - const address = server?.address(); - return typeof address === "object" && address ? address.port : null; -} - -function createServerInstance() { - const nextServer = http.createServer((req, res) => { - handleRequest(req, res).catch((err) => { - console.error("[MockServer] Unhandled error:", err); - json(res, 500, { success: false, error: "Internal mock error" }); - }); - }); - nextServer.on("connection", (socket) => { - openSockets.add(socket); - socket.on("close", () => openSockets.delete(socket)); - }); - nextServer.on("upgrade", (req, socket) => - handleWebSocketUpgrade(req, socket), - ); - return nextServer; -} - -function listen(serverInstance, port) { - return new Promise((resolve, reject) => { - const onError = (err) => { - serverInstance.off("listening", onListening); - reject(err); - }; - const onListening = () => { - serverInstance.off("error", onError); - const address = serverInstance.address(); - const resolvedPort = - typeof address === "object" && address ? address.port : port; - resolve(resolvedPort); - }; - serverInstance.once("error", onError); - serverInstance.once("listening", onListening); - serverInstance.listen(port, "127.0.0.1"); - }); -} - -async function startMockServer(port = DEFAULT_PORT, options = {}) { - if (server) { - return { port: getMockServerPort() ?? port, alreadyRunning: true }; - } - - const preferredPort = - Number.isInteger(port) && port > 0 ? port : DEFAULT_PORT; - const retryIfInUse = options.retryIfInUse === true; - const candidatePorts = retryIfInUse - ? [ - preferredPort, - ...Array.from( - { length: MAX_PORT_RETRY_ATTEMPTS }, - (_, i) => preferredPort + i + 1, - ), - 0, - ] - : [preferredPort]; - - let lastError = null; - for (const candidatePort of candidatePorts) { - const nextServer = createServerInstance(); - try { - const resolvedPort = await listen(nextServer, candidatePort); - server = nextServer; - const retryNote = - resolvedPort === preferredPort - ? "" - : ` (preferred ${preferredPort} unavailable)`; - console.log( - `[MockServer] Listening on http://127.0.0.1:${resolvedPort}${retryNote}`, - ); - return { - port: resolvedPort, - alreadyRunning: false, - requestedPort: preferredPort, - retried: resolvedPort !== preferredPort, - }; - } catch (err) { - try { - nextServer.close(); - } catch { - // The failed candidate may never have reached the listening state. - } - lastError = err; - if (!retryIfInUse || err?.code !== "EADDRINUSE") { - throw err; - } - console.warn( - `[MockServer] Port ${candidatePort} unavailable; trying another local port`, - ); - } - } - - throw lastError ?? new Error("Mock server failed to start"); -} - -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(); - }); - }); -} - +/** + * Re-export shim over the split mock backend at `scripts/mock-api/`. + * + * Kept here so existing import paths (`scripts/mock-api-server.mjs`, + * `app/test/e2e/mock-server.ts`, `scripts/test-rust-with-mock.sh`) keep + * working without churn. New code should import from `./mock-api/index.mjs`. + */ export { DEFAULT_PORT, clearRequestLog, - getMockServerPort, getMockBehavior, + getMockServerPort, getRequestLog, resetMockBehavior, setMockBehavior, setMockBehaviors, startMockServer, stopMockServer, -}; +} from "./mock-api/index.mjs"; diff --git a/scripts/mock-api/admin.mjs b/scripts/mock-api/admin.mjs new file mode 100644 index 000000000..42ee290b2 --- /dev/null +++ b/scripts/mock-api/admin.mjs @@ -0,0 +1,52 @@ +import { json } from "./http.mjs"; +import { + clearRequestLog, + getMockBehavior, + getRequestLog, + resetMockBehavior, + resetMockTunnels, + setMockBehavior, + setMockBehaviors, +} from "./state.mjs"; + +export function handleAdmin(ctx) { + const { method, url, parsedBody, res, getPort } = ctx; + + if (method === "GET" && /^\/__admin\/health\/?$/.test(url)) { + json(res, 200, { ok: true, port: getPort() }); + return true; + } + if (method === "GET" && /^\/__admin\/requests\/?$/.test(url)) { + json(res, 200, { success: true, data: getRequestLog() }); + return true; + } + if (method === "GET" && /^\/__admin\/behavior\/?$/.test(url)) { + json(res, 200, { success: true, data: getMockBehavior() }); + 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(); + json(res, 200, { + success: true, + data: { + behavior: getMockBehavior(), + requestCount: getRequestLog().length, + }, + }); + return true; + } + 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 true; + } + return false; +} diff --git a/scripts/mock-api/http.mjs b/scripts/mock-api/http.mjs new file mode 100644 index 000000000..46f78c93d --- /dev/null +++ b/scripts/mock-api/http.mjs @@ -0,0 +1,74 @@ +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, x-device-fingerprint, x-tauri-version, x-core-version, x-ios-version, x-android-version, x-web-version", + "Access-Control-Max-Age": "86400", +}; + +export function setCors(res) { + for (const [key, value] of Object.entries(CORS_HEADERS)) { + res.setHeader(key, value); + } +} + +export function json(res, status, body) { + setCors(res); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +export function html(res, status, body) { + setCors(res); + res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" }); + res.end(body); +} + +export function requestOrigin(req) { + const host = req.headers.host || "127.0.0.1:18473"; + return `http://${host}`; +} + +export function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString())); + // Don't let stream errors / aborts wedge the dispatcher waiting for + // an end event that will never come. + req.on("error", reject); + req.on("aborted", () => reject(new Error("request aborted"))); + }); +} + +export function tryParseJson(raw) { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +const REDACTED_HEADER_VALUE = "[REDACTED]"; +const SENSITIVE_HEADER_NAMES = new Set([ + "authorization", + "cookie", + "set-cookie", + "proxy-authorization", +]); + +export function normalizeHeaders(headers) { + const entries = Object.entries(headers || {}); + return Object.fromEntries( + entries.map(([key, value]) => { + if (SENSITIVE_HEADER_NAMES.has(String(key).toLowerCase())) { + return [key, REDACTED_HEADER_VALUE]; + } + return [ + key, + Array.isArray(value) ? value.join(", ") : String(value ?? ""), + ]; + }), + ); +} diff --git a/scripts/mock-api/index.mjs b/scripts/mock-api/index.mjs new file mode 100644 index 000000000..a635ada71 --- /dev/null +++ b/scripts/mock-api/index.mjs @@ -0,0 +1,20 @@ +/** + * Public surface of the e2e mock backend. Re-exports the lifecycle + state + * helpers consumed by: + * - `scripts/mock-api-server.mjs` (CLI runner) + * - `scripts/test-rust-with-mock.sh` (Rust integration tests) + * - `app/test/e2e/mock-server.ts` (WDIO specs + Vitest unit setup) + * + * 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 { + DEFAULT_PORT, + clearRequestLog, + getMockBehavior, + getRequestLog, + resetMockBehavior, + setMockBehavior, + setMockBehaviors, +} from "./state.mjs"; diff --git a/scripts/mock-api/routes/auth.mjs b/scripts/mock-api/routes/auth.mjs new file mode 100644 index 000000000..93f1170af --- /dev/null +++ b/scripts/mock-api/routes/auth.mjs @@ -0,0 +1,145 @@ +import { json, setCors } from "../http.mjs"; +import { + behavior, + getDelayMs, + getMockUser, + MOCK_JWT, + sleep, +} from "../state.mjs"; + +export async function handleAuth(ctx) { + const { method, url, res, origin } = ctx; + const mockBehavior = behavior(); + + if ( + method === "POST" && + /^\/telegram\/login-tokens\/[^/]+\/consume\/?$/.test(url) + ) { + if (mockBehavior.token === "expired") { + json(res, 401, { success: false, error: "Token expired or invalid" }); + return true; + } + if (mockBehavior.token === "invalid") { + json(res, 401, { success: false, error: "Invalid token" }); + return true; + } + const jwt = mockBehavior.jwt ? `${MOCK_JWT}-${mockBehavior.jwt}` : MOCK_JWT; + json(res, 200, { success: true, data: { jwtToken: jwt } }); + return true; + } + + if (method === "POST" && /^\/auth\/desktop-exchange\/?$/.test(url)) { + json(res, 200, { + sessionToken: "mock-session-token", + user: { id: "user-123", firstName: "Test", username: "testuser" }, + }); + return true; + } + + if ( + method === "GET" && + (/^\/telegram\/me\/?(\?.*)?$/.test(url) || + /^\/auth\/me\/?(\?.*)?$/.test(url)) + ) { + const delayMs = getDelayMs("telegramMeDelayMs"); + if (delayMs > 0) { + await sleep(delayMs); + } + if (mockBehavior.telegramMeStatus) { + const status = Number(mockBehavior.telegramMeStatus) || 500; + json(res, status, { + success: false, + error: mockBehavior.telegramMeError || "Mock telegram/me failure", + }); + return true; + } + if (mockBehavior.session === "revoked") { + json(res, 401, { success: false, error: "Unauthorized" }); + return true; + } + json(res, 200, { success: true, data: getMockUser() }); + return true; + } + + if (method === "GET" && /^\/auth\/integrations\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: [] }); + return true; + } + + if (method === "POST" && /^\/auth\/email\/send-link\/?$/.test(url)) { + // Gap fill: passwordless magic-link send. Always succeed in mock. + json(res, 200, { + success: true, + data: { sent: true, expiresAt: new Date(Date.now() + 600000).toISOString() }, + }); + return true; + } + + 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 true; + } + setCors(res); + res.writeHead(302, { Location: redirectUrl }); + res.end(); + return true; + } + + 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 true; + } + json(res, 200, { + success: true, + data: { oauthUrl: `${origin}/mock-telegram-oauth` }, + }); + return true; + } + + if (method === "GET" && /^\/auth\/notion\/connect\/?(\?.*)?$/.test(url)) { + if (mockBehavior.notionTokenRevoked === "true") { + json(res, 401, { success: false, error: "OAuth token has been revoked" }); + return true; + } + const workspace = mockBehavior.notionWorkspace || "Test User's Workspace"; + json(res, 200, { + success: true, + data: { oauthUrl: `${origin}/mock-notion-oauth`, workspace }, + }); + return true; + } + + if (method === "GET" && /^\/auth\/google\/connect\/?(\?.*)?$/.test(url)) { + if (mockBehavior.gmailTokenRevoked === "true") { + json(res, 401, { success: false, error: "OAuth token has been revoked" }); + return true; + } + if (mockBehavior.gmailTokenExpired === "true") { + json(res, 401, { success: false, error: "OAuth token has expired" }); + return true; + } + json(res, 200, { + success: true, + data: { oauthUrl: `${origin}/mock-google-oauth` }, + }); + return true; + } + + if (method === "POST" && /^\/auth\/telegram\/?$/.test(url)) { + // Gap fill: telegram login callback exchange. + json(res, 200, { success: true, data: { jwtToken: MOCK_JWT } }); + return true; + } + + // /mock-oauth, /mock-oauth/, and the legacy + // /mock--oauth aliases are handled by routes/oauth.mjs, which + // actually completes the OAuth flow via deep links. + + return false; +} diff --git a/scripts/mock-api/routes/conversations.mjs b/scripts/mock-api/routes/conversations.mjs new file mode 100644 index 000000000..2019d5668 --- /dev/null +++ b/scripts/mock-api/routes/conversations.mjs @@ -0,0 +1,102 @@ +import { json } from "../http.mjs"; + +// 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; + + // /conversations + if (method === "GET" && /^\/conversations\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: [] }); + return true; + } + if (method === "POST" && /^\/conversations\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { + id: "conv_mock_" + Date.now(), + ...(parsedBody || {}), + createdAt: new Date().toISOString(), + }, + }); + return true; + } + const conversationItemMatch = url.match( + /^\/conversations\/([^/?]+)\/?(\?.*)?$/, + ); + if (conversationItemMatch) { + if (method === "GET") { + json(res, 200, { + success: true, + data: { id: conversationItemMatch[1], messages: [] }, + }); + return true; + } + if (method === "DELETE") { + json(res, 200, { success: true, data: { deleted: true } }); + return true; + } + } + + // /messages + if (method === "GET" && /^\/messages\/matches\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: { matches: [] } }); + return true; + } + if (method === "GET" && /^\/messages\/paging\/pages\/?(\?.*)?$/.test(url)) { + json(res, 200, { + success: true, + data: { pages: [], nextCursor: null }, + }); + return true; + } + if (method === "GET" && /^\/messages\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: [] }); + return true; + } + if (method === "POST" && /^\/messages\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { + id: "msg_mock_" + Date.now(), + ...(parsedBody || {}), + createdAt: new Date().toISOString(), + }, + }); + return true; + } + + // /channels + if (method === "GET" && /^\/channels\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: [] }); + return true; + } + const channelItemMatch = url.match(/^\/channels\/([^/?]+)\/?(\?.*)?$/); + if (channelItemMatch) { + if (method === "GET") { + json(res, 200, { + success: true, + data: { id: channelItemMatch[1], name: "Mock Channel" }, + }); + return true; + } + if (method === "PATCH") { + json(res, 200, { + success: true, + data: { id: channelItemMatch[1], ...(parsedBody || {}) }, + }); + return true; + } + } + + // /notifications + if (method === "GET" && /^\/notifications\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: [] }); + return true; + } + + return false; +} diff --git a/scripts/mock-api/routes/cron.mjs b/scripts/mock-api/routes/cron.mjs new file mode 100644 index 000000000..39832ab20 --- /dev/null +++ b/scripts/mock-api/routes/cron.mjs @@ -0,0 +1,63 @@ +import { json } from "../http.mjs"; + +// 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; + + if (method === "GET" && /^\/settings\/cron-jobs\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: [] }); + return true; + } + if (method === "POST" && /^\/settings\/cron-jobs\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { + id: "cron_mock_" + Date.now(), + ...(parsedBody || {}), + createdAt: new Date().toISOString(), + }, + }); + return true; + } + const cronItem = url.match(/^\/settings\/cron-jobs\/([^/?]+)\/?(\?.*)?$/); + if (cronItem && (method === "PATCH" || method === "DELETE")) { + json(res, 200, { + success: true, + data: { id: cronItem[1], deleted: method === "DELETE" }, + }); + return true; + } + + if ( + method === "GET" && + /^\/settings\/webhooks-triggers\/?(\?.*)?$/.test(url) + ) { + json(res, 200, { success: true, data: [] }); + return true; + } + if (method === "POST" && /^\/settings\/webhooks-triggers\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { + id: "trg_mock_" + Date.now(), + ...(parsedBody || {}), + createdAt: new Date().toISOString(), + }, + }); + return true; + } + const trgItem = url.match( + /^\/settings\/webhooks-triggers\/([^/?]+)\/?(\?.*)?$/, + ); + if (trgItem && (method === "PATCH" || method === "DELETE")) { + json(res, 200, { + success: true, + data: { id: trgItem[1], deleted: method === "DELETE" }, + }); + return true; + } + + return false; +} diff --git a/scripts/mock-api/routes/integrations.mjs b/scripts/mock-api/routes/integrations.mjs new file mode 100644 index 000000000..33c9f3049 --- /dev/null +++ b/scripts/mock-api/routes/integrations.mjs @@ -0,0 +1,293 @@ +import { json } from "../http.mjs"; +import { behavior, parseBehaviorJson, setMockBehavior } from "../state.mjs"; + +export function handleIntegrations(ctx) { + const { method, url, parsedBody, res } = ctx; + const mockBehavior = behavior(); + + // ── Telegram ─────────────────────────────────────────────── + if (method === "POST" && /^\/telegram\/command\/?$/.test(url)) { + if (mockBehavior.telegramUnauthorized === "true") { + json(res, 403, { + success: false, + error: "Unauthorized: insufficient permissions", + }); + return true; + } + if (mockBehavior.telegramCommandError === "true") { + json(res, 400, { success: false, error: "Invalid command format" }); + return true; + } + json(res, 200, { + success: true, + data: { result: "Command executed successfully" }, + }); + return true; + } + + 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 true; + } + + if (method === "POST" && /^\/telegram\/webhook\/configure\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { + webhookUrl: "https://api.example.com/webhook/telegram", + active: true, + }, + }); + return true; + } + + if (method === "POST" && /^\/telegram\/disconnect\/?$/.test(url)) { + json(res, 200, { success: true, data: { disconnected: true } }); + return true; + } + + // ── Notion ───────────────────────────────────────────────── + 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 true; + } + + // ── Gmail ────────────────────────────────────────────────── + 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 true; + } + + if (method === "POST" && /^\/gmail\/disconnect\/?$/.test(url)) { + json(res, 200, { success: true, data: { disconnected: true } }); + return true; + } + + 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 true; + } + + // ── 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 true; + } + + // ── OpenAI proxy ─────────────────────────────────────────── + if (method === "GET" && /^\/openai\/v1\/models\/?(\?.*)?$/.test(url)) { + json(res, 200, { data: [{ id: "e2e-mock-model", object: "model" }] }); + return true; + } + + if (method === "POST" && /^\/openai\/v1\/chat\/completions\/?$/.test(url)) { + json(res, 200, { + choices: [ + { + message: { role: "assistant", content: "Hello from e2e mock agent" }, + }, + ], + }); + return true; + } + + // ── Composio ─────────────────────────────────────────────── + if ( + method === "GET" && + /^\/agent-integrations\/composio\/connections\/?(\?.*)?$/.test(url) + ) { + const connections = parseBehaviorJson("composioConnections", [ + { id: "c1", toolkit: "gmail", status: "ACTIVE" }, + ]); + json(res, 200, { success: true, data: { connections } }); + return true; + } + + if ( + method === "GET" && + /^\/agent-integrations\/composio\/triggers\/available(\?.*)?$/.test(url) + ) { + const triggers = parseBehaviorJson("composioAvailableTriggers", [ + { slug: "GMAIL_NEW_GMAIL_MESSAGE", scope: "static" }, + ]); + json(res, 200, { success: true, data: { triggers } }); + return true; + } + + if ( + method === "GET" && + /^\/agent-integrations\/composio\/triggers(\?.*)?$/.test(url) + ) { + const triggers = parseBehaviorJson("composioActiveTriggers", []); + json(res, 200, { success: true, data: { triggers } }); + return true; + } + + if ( + method === "POST" && + /^\/agent-integrations\/composio\/triggers\/?$/.test(url) + ) { + if (mockBehavior.composioEnableFails === "1") { + json(res, 500, { success: false, error: "Mock enable trigger failure" }); + return true; + } + const slug = + typeof parsedBody?.slug === "string" ? parsedBody.slug.trim() : ""; + const connectionId = + typeof parsedBody?.connectionId === "string" + ? parsedBody.connectionId.trim() + : ""; + if (!slug) { + json(res, 400, { success: false, error: "Missing required field: slug" }); + return true; + } + if (!connectionId) { + json(res, 400, { + success: false, + error: "Missing required field: connectionId", + }); + return true; + } + const triggerId = `ti-${Date.now()}`; + const active = parseBehaviorJson("composioActiveTriggers", []); + active.push({ + id: triggerId, + slug, + toolkit: slug.split("_")[0]?.toLowerCase() ?? "", + connectionId, + ...(parsedBody?.triggerConfig + ? { triggerConfig: parsedBody.triggerConfig } + : {}), + }); + setMockBehavior("composioActiveTriggers", JSON.stringify(active)); + json(res, 200, { + success: true, + data: { triggerId, slug, connectionId }, + }); + return true; + } + + if ( + method === "DELETE" && + /^\/agent-integrations\/composio\/triggers\/[^/]+\/?$/.test(url) + ) { + let id = url.split("/").filter(Boolean).pop() ?? ""; + id = id.split("?")[0]; + if (!id) { + json(res, 400, { success: false, error: "Missing trigger id" }); + return true; + } + try { + id = decodeURIComponent(id); + } catch { + json(res, 400, { success: false, error: "Invalid trigger id encoding" }); + return true; + } + const active = parseBehaviorJson("composioActiveTriggers", []); + const next = active.filter((t) => t.id !== id); + const deleted = next.length !== active.length; + if (deleted) { + setMockBehavior("composioActiveTriggers", JSON.stringify(next)); + } + json(res, 200, { success: true, data: { deleted } }); + return true; + } + + // Composio gap fills. + if ( + method === "GET" && + /^\/agent-integrations\/composio\/github\/repos\/?(\?.*)?$/.test(url) + ) { + json(res, 200, { success: true, data: { repos: [] } }); + return true; + } + + if ( + method === "GET" && + /^\/agent-integrations\/composio\/tools\/?(\?.*)?$/.test(url) + ) { + json(res, 200, { success: true, data: { tools: [] } }); + return true; + } + + // ── Apify ────────────────────────────────────────────────── + // Gap fill — minimal stubs for run polling. + const apifyMatch = url.match( + /^\/agent-integrations\/apify\/runs\/([^/?]+)(\/results)?\/?(\?.*)?$/, + ); + if (apifyMatch && method === "GET") { + const [, runId, isResults] = apifyMatch; + if (isResults) { + json(res, 200, { success: true, data: { items: [] } }); + } else { + json(res, 200, { + success: true, + data: { id: runId, status: "SUCCEEDED", finishedAt: new Date().toISOString() }, + }); + } + return true; + } + + return false; +} diff --git a/scripts/mock-api/routes/invites.mjs b/scripts/mock-api/routes/invites.mjs new file mode 100644 index 000000000..0fd72a1f4 --- /dev/null +++ b/scripts/mock-api/routes/invites.mjs @@ -0,0 +1,26 @@ +import { json } from "../http.mjs"; + +export function handleInvites(ctx) { + const { method, url, res } = ctx; + + if (method === "POST" && /^\/invite\/redeem\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { message: "Invite code redeemed successfully" }, + }); + return true; + } + if (method === "GET" && /^\/invite\/my-codes\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: [] }); + return true; + } + if ( + method === "GET" && + /^\/invite\/status(?:\/[^/?]+)?\/?(\?.*)?$/.test(url) + ) { + json(res, 200, { success: true, data: { valid: true } }); + return true; + } + + return false; +} diff --git a/scripts/mock-api/routes/oauth.mjs b/scripts/mock-api/routes/oauth.mjs new file mode 100644 index 000000000..869beb7e5 --- /dev/null +++ b/scripts/mock-api/routes/oauth.mjs @@ -0,0 +1,228 @@ +import { html, json, setCors } from "../http.mjs"; +import { behavior, MOCK_JWT } from "../state.mjs"; + +/** + * Mock OAuth provider landing page + callback handlers. + * + * The real flow looks like: + * 1. App calls `GET /auth//connect` → backend returns `oauthUrl` + * pointing at the actual provider (Google, Notion, Slack, …). + * 2. App opens that URL in the system browser. + * 3. User logs in at the provider, provider redirects back to the backend + * callback, the backend exchanges the code and finally redirects to the + * desktop deep link `openhuman://oauth/success?integrationId=…&provider=…` + * (see app/src/utils/desktopDeepLinkListener.ts). + * 4. The Tauri shell receives the deep link, the app marks the integration + * connected. + * + * For e2e we collapse all of that into local HTTP. `/auth//connect` + * already points the app at `${origin}/mock--oauth`; this module + * makes that page actually finish the dance by issuing the deep link. + * + * Behavior knobs (via `setMockBehavior`): + * oauthAutoRedirectMs — delay before the deep-link redirect fires + * (default 50). Set `manual` to require a click. + * oauthIntegrationId — integrationId param in the success deep link + * (default `mock--integration`). + * oauthForceError — when "true", redirect to the error deep link. + * oauthErrorCode — error code passed to the error deep link. + * + * Query-string overrides (per request, win over behavior knobs): + * ?provider= — overrides provider inferred from the path. + * ?integrationId= — overrides the integrationId emitted. + * ?manual=1 — disable auto-redirect (test wants to click). + * ?error= — emit an error deep link instead of success. + */ +export function handleOAuth(ctx) { + const { method, url, parsedBody, res } = ctx; + + // /mock-oauth/ and the legacy /mock--oauth aliases. + const newStyle = url.match(/^\/mock-oauth\/([a-z][a-z0-9_-]*)\/?(\?.*)?$/i); + const legacy = url.match( + /^\/mock-(telegram|notion|google|gmail|slack|discord|twitter|github)-oauth\/?(\?.*)?$/i, + ); + const legacyGeneric = !newStyle && !legacy && /^\/mock-oauth\/?(\?.*)?$/.test(url); + + if (method === "GET" && (newStyle || legacy || legacyGeneric)) { + const provider = newStyle?.[1] || legacy?.[1] || "generic"; + const params = parseQuery(url); + const mockBehavior = behavior(); + const integrationId = + params.integrationId || + mockBehavior.oauthIntegrationId || + `mock-${provider}-integration`; + const errorCode = + params.error || + (mockBehavior.oauthForceError === "true" + ? mockBehavior.oauthErrorCode || "access_denied" + : null); + const manual = + params.manual === "1" || mockBehavior.oauthAutoRedirectMs === "manual"; + const autoRedirectMs = manual + ? null + : clampDelay(mockBehavior.oauthAutoRedirectMs, 50); + + const target = errorCode + ? `openhuman://oauth/error?provider=${encodeURIComponent( + params.provider || provider, + )}&error=${encodeURIComponent(errorCode)}` + : `openhuman://oauth/success?integrationId=${encodeURIComponent( + integrationId, + )}&provider=${encodeURIComponent(params.provider || provider)}`; + + html(res, 200, renderOAuthPage({ provider, target, autoRedirectMs, errorCode })); + return true; + } + + // Generic callback exchange. Real providers each hit their own + // backend-specific URL; for e2e a single endpoint per provider that + // always returns a session token is enough. + const callbackMatch = url.match(/^\/auth\/([a-z][a-z0-9_-]*)\/callback\/?(\?.*)?$/i); + if (method === "GET" && callbackMatch) { + const provider = callbackMatch[1]; + const params = parseQuery(url); + const mockBehavior = behavior(); + + if (mockBehavior.oauthForceError === "true" || params.error) { + const errorCode = + params.error || mockBehavior.oauthErrorCode || "access_denied"; + // Redirect to the desktop error deep link. + setCors(res); + res.writeHead(302, { + Location: `openhuman://oauth/error?provider=${encodeURIComponent( + provider, + )}&error=${encodeURIComponent(errorCode)}`, + }); + res.end(); + return true; + } + + const integrationId = + params.integrationId || + mockBehavior.oauthIntegrationId || + `mock-${provider}-integration`; + setCors(res); + res.writeHead(302, { + Location: `openhuman://oauth/success?integrationId=${encodeURIComponent( + integrationId, + )}&provider=${encodeURIComponent(provider)}`, + }); + res.end(); + return true; + } + + // Backend-style code-for-token POST exchange, in case any provider + // routes through the desktop app rather than the deep link. + if ( + method === "POST" && + /^\/auth\/[a-z][a-z0-9_-]*\/exchange\/?$/i.test(url) + ) { + const provider = url.split("/")[2] ?? "generic"; + const mockBehavior = behavior(); + if (mockBehavior.oauthForceError === "true") { + json(res, 400, { + success: false, + error: mockBehavior.oauthErrorCode || "access_denied", + }); + return true; + } + const integrationId = + parsedBody?.integrationId || + mockBehavior.oauthIntegrationId || + `mock-${provider}-integration`; + json(res, 200, { + success: true, + data: { + provider, + integrationId, + sessionToken: "mock-session-token", + jwtToken: MOCK_JWT, + }, + }); + return true; + } + + return false; +} + +function parseQuery(url) { + const qIndex = url.indexOf("?"); + if (qIndex < 0) return {}; + const out = {}; + const search = url.slice(qIndex + 1); + for (const pair of search.split("&")) { + if (!pair) continue; + const eq = pair.indexOf("="); + const key = eq < 0 ? pair : pair.slice(0, eq); + const raw = eq < 0 ? "" : pair.slice(eq + 1); + try { + out[decodeURIComponent(key)] = decodeURIComponent(raw.replace(/\+/g, " ")); + } catch { + out[key] = raw; + } + } + return out; +} + +function clampDelay(raw, fallback) { + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return Math.min(parsed, 30000); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[c]); +} + +function renderOAuthPage({ provider, target, autoRedirectMs, errorCode }) { + const safeTarget = escapeHtml(target); + const safeProvider = escapeHtml(provider); + const heading = errorCode + ? `${safeProvider} — sign-in failed (${escapeHtml(errorCode)})` + : `${safeProvider} — mock sign-in`; + const blurb = errorCode + ? "This mock provider is simulating a failed OAuth. The desktop app should receive an error deep link." + : "This is the mock OAuth provider. The desktop app should receive a success deep link."; + + const autoRedirectScript = + autoRedirectMs === null + ? "" + : ``; + + const metaRefresh = + autoRedirectMs === null + ? "" + : ``; + + return ` + + + + Mock OAuth · ${safeProvider} + ${metaRefresh} + + + +

${heading}

+

${blurb}

+

Target: ${safeTarget}

+

Continue to OpenHuman

+ ${autoRedirectScript} + +`; +} diff --git a/scripts/mock-api/routes/payments.mjs b/scripts/mock-api/routes/payments.mjs new file mode 100644 index 000000000..65c290012 --- /dev/null +++ b/scripts/mock-api/routes/payments.mjs @@ -0,0 +1,262 @@ +import { json } from "../http.mjs"; +import { behavior } from "../state.mjs"; + +export function handlePayments(ctx) { + const { method, url, parsedBody, res } = ctx; + const mockBehavior = behavior(); + + if (method === "GET" && /^\/payments\/credits\/balance\/?(\?.*)?$/.test(url)) { + json(res, 200, { + success: true, + data: { balanceUsd: 10, topUpBalanceUsd: 0, topUpBaselineUsd: 0 }, + }); + return true; + } + + if ( + method === "GET" && + (/^\/payments\/plan\/?(\?.*)?$/.test(url) || + /^\/payments\/stripe\/currentPlan\/?(\?.*)?$/.test(url)) + ) { + const plan = mockBehavior.plan || "FREE"; + const isActive = mockBehavior.planActive === "true"; + const periodEnd = new Date(Date.now() + 30 * 86400000).toISOString(); + json(res, 200, { + success: true, + data: { + plan, + hasActiveSubscription: isActive, + planExpiry: isActive ? periodEnd : null, + subscription: isActive + ? { id: "sub_mock_1", status: "active", currentPeriodEnd: periodEnd } + : null, + }, + }); + return true; + } + + if ( + method === "POST" && + (/^\/payments\/stripe\/checkout\/?$/.test(url) || + /^\/payments\/stripe\/purchasePlan\/?$/.test(url)) + ) { + if (mockBehavior.purchaseError === "true") { + json(res, 500, { success: false, error: "Payment service unavailable" }); + return true; + } + json(res, 200, { + success: true, + data: { + sessionId: "cs_mock_" + Date.now(), + checkoutUrl: null, + }, + }); + return true; + } + + if (method === "POST" && /^\/payments\/stripe\/portal\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { portalUrl: "https://billing.stripe.com/mock-portal" }, + }); + return true; + } + + if (method === "POST" && /^\/payments\/coinbase\/charge\/?$/.test(url)) { + if (mockBehavior.coinbaseError === "true") { + json(res, 500, { success: false, error: "Coinbase service unavailable" }); + return true; + } + json(res, 200, { + success: true, + data: { + gatewayTransactionId: "charge_mock_" + Date.now(), + hostedUrl: "https://commerce.coinbase.com/mock-charge", + status: "NEW", + expiresAt: new Date(Date.now() + 3600000).toISOString(), + }, + }); + return true; + } + + if (method === "POST" && /^\/payments\/purchase\/?$/.test(url)) { + const plan = parsedBody?.plan || mockBehavior.plan || "BASIC"; + json(res, 200, { + success: true, + data: { + sessionId: "cs_mock_" + Date.now(), + url: "https://checkout.stripe.com/mock-purchase", + plan, + }, + }); + return true; + } + + if ( + method === "GET" && + /^\/payments\/credits\/auto-recharge\/?(\?.*)?$/.test(url) + ) { + json(res, 200, { + success: true, + data: { + enabled: false, + thresholdUsd: 5, + rechargeAmountUsd: 10, + weeklyLimitUsd: 50, + spentThisWeekUsd: 0, + weekStartDate: new Date().toISOString(), + inFlight: false, + hasSavedPaymentMethod: false, + lastTriggeredAt: null, + lastRechargeAt: null, + }, + }); + return true; + } + + if ( + method === "PATCH" && + /^\/payments\/credits\/auto-recharge\/?$/.test(url) + ) { + // Gap fill: update auto-recharge config. Echo back the patched values. + json(res, 200, { + success: true, + data: { + enabled: parsedBody?.enabled ?? false, + thresholdUsd: parsedBody?.thresholdUsd ?? 5, + rechargeAmountUsd: parsedBody?.rechargeAmountUsd ?? 10, + weeklyLimitUsd: parsedBody?.weeklyLimitUsd ?? 50, + spentThisWeekUsd: 0, + weekStartDate: new Date().toISOString(), + inFlight: false, + hasSavedPaymentMethod: false, + lastTriggeredAt: null, + lastRechargeAt: null, + }, + }); + return true; + } + + if (method === "GET" && /^\/payments\/cards\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: { cards: [], defaultCardId: null } }); + return true; + } + + if ( + method === "GET" && + /^\/payments\/credits\/auto-recharge\/cards\/?(\?.*)?$/.test(url) + ) { + json(res, 200, { success: true, data: { cards: [], defaultCardId: null } }); + return true; + } + + // ── Gap fills ───────────────────────────────────────────────────── + + if ( + method === "POST" && + /^\/payments\/credits\/auto-recharge\/cards\/setup-intent\/?$/.test(url) + ) { + json(res, 200, { + success: true, + data: { + clientSecret: "seti_mock_" + Date.now() + "_secret_mock", + setupIntentId: "seti_mock_" + Date.now(), + }, + }); + return true; + } + + if ( + method === "DELETE" && + /^\/payments\/credits\/auto-recharge\/cards\/[^/]+\/?$/.test(url) + ) { + json(res, 200, { success: true, data: { deleted: true } }); + return true; + } + + if ( + method === "GET" && + /^\/payments\/credits\/transactions\/?(\?.*)?$/.test(url) + ) { + json(res, 200, { + success: true, + data: { + transactions: [], + nextCursor: null, + }, + }); + return true; + } + + if (method === "POST" && /^\/payments\/credits\/top-up\/?$/.test(url)) { + // Don't collapse an explicit 0 into the default — `Number(0) || 10` is + // a classic falsy-coalesce bug. Use Number.isFinite so any non-numeric + // body still falls back to 10. + const rawAmount = parsedBody?.amountUsd; + const parsedAmount = rawAmount == null ? 10 : Number(rawAmount); + const amount = + Number.isFinite(parsedAmount) && parsedAmount >= 0 ? parsedAmount : 10; + json(res, 200, { + success: true, + data: { + sessionId: "cs_mock_topup_" + Date.now(), + checkoutUrl: null, + amountUsd: amount, + }, + }); + return true; + } + + 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 true; + } + + if (method === "GET" && /^\/billing\/current-plan\/?(\?.*)?$/.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 true; + } + + return false; +} diff --git a/scripts/mock-api/routes/user.mjs b/scripts/mock-api/routes/user.mjs new file mode 100644 index 000000000..94a284ac0 --- /dev/null +++ b/scripts/mock-api/routes/user.mjs @@ -0,0 +1,298 @@ +import { json } from "../http.mjs"; +import { behavior, getMockTeam } from "../state.mjs"; + +export function handleUser(ctx) { + const { method, url, res, origin } = ctx; + const mockBehavior = behavior(); + + if (method === "GET" && /^\/settings\/?(\?.*)?$/.test(url)) { + json(res, 200, { + success: true, + data: { _id: "e2e-user-1", username: "e2e" }, + }); + return true; + } + + if (method === "GET" && /^\/teams\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: [getMockTeam()] }); + return true; + } + + if (method === "GET" && /^\/teams\/me\/usage\/?(\?.*)?$/.test(url)) { + json(res, 200, { + success: true, + data: { + cycleBudgetUsd: 10, + remainingUsd: 10, + cycleLimit5hr: 0, + cycleLimit7day: 0, + fiveHourCapUsd: 5, + fiveHourResetsAt: null, + cycleStartDate: new Date().toISOString(), + cycleEndsAt: new Date( + Date.now() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(), + bypassCycleLimit: false, + }, + }); + return true; + } + + if (method === "POST" && /^\/teams\/join\/?$/.test(url)) { + // Gap fill: accept team invite. + json(res, 200, { success: true, data: getMockTeam() }); + return true; + } + + if (method === "GET" && /^\/users\/?(\?.*)?$/.test(url)) { + // Gap fill: list users (admin/team context). Empty list keeps the UI quiet. + json(res, 200, { success: true, data: [] }); + return true; + } + + if ( + method === "POST" && + /^\/telegram\/settings\/onboarding-complete\/?$/.test(url) + ) { + json(res, 200, { success: true, data: {} }); + return true; + } + if (method === "POST" && /^\/settings\/onboarding-complete\/?$/.test(url)) { + json(res, 200, { success: true, data: {} }); + return true; + } + + if (method === "GET" && /^\/referral\/stats\/?(\?.*)?$/.test(url)) { + json(res, 200, { + success: true, + data: { + referralCode: "MOCKREF1", + referralLink: `${origin}/#/rewards?ref=MOCKREF1`, + totals: { + totalRewardUsd: 10, + pendingCount: 1, + convertedCount: 2, + }, + referrals: [ + { + id: "ref-row-1", + referredUserId: "user-456", + status: "pending", + createdAt: new Date(Date.now() - 86400000).toISOString(), + }, + { + id: "ref-row-2", + referredUserId: "user-789", + status: "converted", + createdAt: new Date(Date.now() - 172800000).toISOString(), + convertedAt: new Date().toISOString(), + rewardUsd: 5, + }, + ], + appliedReferralCode: null, + canApplyReferral: true, + }, + }); + return true; + } + + if (method === "POST" && /^\/referral\/claim\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { ok: true, message: "Referral claimed" }, + }); + return true; + } + + if (method === "GET" && /^\/rewards\/me\/?(\?.*)?$/.test(url)) { + if (mockBehavior.rewardsServiceError === "true") { + json(res, 503, { + success: false, + error: "Rewards service unavailable", + }); + return true; + } + json(res, 200, { success: true, data: buildRewardsSnapshot(mockBehavior) }); + return true; + } + + return false; +} + +function buildRewardsSnapshot(mockBehavior) { + const scenario = mockBehavior.rewardsScenario || "default"; + const lastSyncedAt = + mockBehavior.rewardsLastSyncedAt || new Date().toISOString(); + + const baseAchievements = [ + { + id: "STREAK_7", + title: "7-Day Streak", + description: "Use OpenHuman on seven consecutive active days.", + actionLabel: "Keep your streak alive for 7 days", + unlocked: false, + progressLabel: "0 / 7 days", + roleId: "role-streak-7", + discordRoleStatus: "not_linked", + creditAmountUsd: null, + }, + { + id: "DISCORD_MEMBER", + title: "Discord Member", + description: "Join the OpenHuman Discord server.", + actionLabel: "Connect Discord and join the server", + unlocked: false, + progressLabel: "Not joined", + roleId: "role-discord-member", + discordRoleStatus: "not_linked", + creditAmountUsd: null, + }, + { + id: "PLAN_PRO", + title: "Pro Supporter", + description: "Upgrade to the Pro plan.", + actionLabel: "Upgrade to Pro", + unlocked: false, + progressLabel: "Locked", + roleId: "role-plan-pro", + discordRoleStatus: "not_assigned", + creditAmountUsd: 5, + }, + ]; + + const defaultDiscord = { + linked: false, + discordId: null, + inviteUrl: "https://discord.gg/openhuman", + membershipStatus: "not_linked", + }; + const memberDiscord = { + linked: true, + discordId: "discord-mock-123", + inviteUrl: "https://discord.gg/openhuman", + membershipStatus: "member", + }; + const zeroMetrics = { + currentStreakDays: 0, + longestStreakDays: 0, + cumulativeTokens: 0, + featuresUsedCount: 0, + trackedFeaturesCount: 6, + lastEvaluatedAt: lastSyncedAt, + lastSyncedAt, + }; + + switch (scenario) { + case "activity_unlocked": + return { + discord: defaultDiscord, + summary: { + unlockedCount: 1, + totalCount: 3, + assignedDiscordRoleCount: 0, + plan: "FREE", + hasActiveSubscription: false, + }, + metrics: { + ...zeroMetrics, + currentStreakDays: 7, + longestStreakDays: 7, + cumulativeTokens: 250000, + featuresUsedCount: 4, + }, + achievements: [ + { + ...baseAchievements[0], + unlocked: true, + progressLabel: "Unlocked", + discordRoleStatus: "not_linked", + }, + baseAchievements[1], + baseAchievements[2], + ], + }; + case "integration_unlocked": + return { + discord: memberDiscord, + summary: { + unlockedCount: 1, + totalCount: 3, + assignedDiscordRoleCount: 1, + plan: "FREE", + hasActiveSubscription: false, + }, + metrics: { ...zeroMetrics }, + achievements: [ + baseAchievements[0], + { + ...baseAchievements[1], + unlocked: true, + progressLabel: "Unlocked", + discordRoleStatus: "assigned", + }, + baseAchievements[2], + ], + }; + case "plan_unlocked": + return { + discord: defaultDiscord, + summary: { + unlockedCount: 1, + totalCount: 3, + assignedDiscordRoleCount: 0, + plan: "PRO", + hasActiveSubscription: true, + }, + metrics: { ...zeroMetrics }, + achievements: [ + baseAchievements[0], + baseAchievements[1], + { + ...baseAchievements[2], + unlocked: true, + progressLabel: "Unlocked", + discordRoleStatus: "not_linked", + }, + ], + }; + case "high_usage": + case "post_restart": + return { + discord: memberDiscord, + summary: { + unlockedCount: 3, + totalCount: 3, + assignedDiscordRoleCount: 1, + plan: "PRO", + hasActiveSubscription: true, + }, + metrics: { + ...zeroMetrics, + currentStreakDays: 14, + longestStreakDays: 21, + cumulativeTokens: 12500000, + featuresUsedCount: 6, + }, + achievements: baseAchievements.map((a) => ({ + ...a, + unlocked: true, + progressLabel: "Unlocked", + discordRoleStatus: "assigned", + })), + }; + case "default": + default: + return { + discord: defaultDiscord, + summary: { + unlockedCount: 0, + totalCount: 3, + assignedDiscordRoleCount: 0, + plan: "FREE", + hasActiveSubscription: false, + }, + metrics: { ...zeroMetrics }, + achievements: baseAchievements, + }; + } +} diff --git a/scripts/mock-api/routes/version.mjs b/scripts/mock-api/routes/version.mjs new file mode 100644 index 000000000..8332364e7 --- /dev/null +++ b/scripts/mock-api/routes/version.mjs @@ -0,0 +1,22 @@ +import { json } from "../http.mjs"; + +// Gap fill: version-check ping used by daemonHealthService. +export function handleVersion(ctx) { + const { method, url, res } = ctx; + + if (/^\/version-check\/?(\?.*)?$/.test(url)) { + if (method === "GET" || method === "POST") { + json(res, 200, { + success: true, + data: { + ok: true, + serverVersion: "0.0.0-mock", + minClientVersion: "0.0.0", + }, + }); + return true; + } + } + + return false; +} diff --git a/scripts/mock-api/routes/webhooks.mjs b/scripts/mock-api/routes/webhooks.mjs new file mode 100644 index 000000000..fc6ad6f03 --- /dev/null +++ b/scripts/mock-api/routes/webhooks.mjs @@ -0,0 +1,89 @@ +import { json } from "../http.mjs"; +import { createMockTunnel, getMockTunnels } from "../state.mjs"; + +export function handleWebhooks(ctx) { + const { method, url, parsedBody, res } = ctx; + const mockTunnels = getMockTunnels(); + + if (method === "POST" && /^\/webhooks\/core\/?$/.test(url)) { + const tunnel = createMockTunnel(parsedBody || {}); + mockTunnels.unshift(tunnel); + json(res, 200, { success: true, data: tunnel }); + return true; + } + + if (method === "GET" && /^\/webhooks\/core\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: mockTunnels }); + return true; + } + + if (method === "GET" && /^\/webhooks\/core\/bandwidth\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: { remainingBudgetUsd: 10 } }); + return true; + } + + const webhookCoreMatch = url.match(/^\/webhooks\/core\/([^/?]+)\/?(\?.*)?$/); + if (webhookCoreMatch) { + const [, tunnelId] = webhookCoreMatch; + const tunnelIndex = mockTunnels.findIndex((entry) => entry.id === tunnelId); + const tunnel = tunnelIndex >= 0 ? mockTunnels[tunnelIndex] : null; + + if (!tunnel) { + json(res, 404, { success: false, error: "Tunnel not found" }); + return true; + } + + if (method === "GET") { + json(res, 200, { success: true, data: tunnel }); + return true; + } + + if (method === "PATCH") { + const updated = { + ...tunnel, + ...(parsedBody || {}), + updatedAt: new Date().toISOString(), + }; + mockTunnels[tunnelIndex] = updated; + json(res, 200, { success: true, data: updated }); + return true; + } + + if (method === "DELETE") { + mockTunnels.splice(tunnelIndex, 1); + json(res, 200, { success: true, data: tunnel }); + return true; + } + } + + // ── Webhook ingress (gap fill) ───────────────────────────── + // The ingress side accepts inbound HTTP from third parties via a tunnel + // ID; in e2e we just accept-and-acknowledge so the UI sees activity. + if (method === "GET" && /^\/webhooks\/ingress\/?(\?.*)?$/.test(url)) { + json(res, 200, { success: true, data: [] }); + return true; + } + + const ingressMatch = url.match(/^\/webhooks\/ingress\/([^/?]+)\/?(\?.*)?$/); + if (ingressMatch) { + if (method === "GET") { + json(res, 200, { + success: true, + data: { id: ingressMatch[1], events: [] }, + }); + return true; + } + if (method === "POST") { + json(res, 200, { + success: true, + data: { + ingressId: ingressMatch[1], + receivedAt: new Date().toISOString(), + }, + }); + return true; + } + } + + return false; +} diff --git a/scripts/mock-api/server.mjs b/scripts/mock-api/server.mjs new file mode 100644 index 000000000..958fa6f49 --- /dev/null +++ b/scripts/mock-api/server.mjs @@ -0,0 +1,211 @@ +import http from "node:http"; + +import { handleAdmin } from "./admin.mjs"; +import { + json, + normalizeHeaders, + readBody, + requestOrigin, + setCors, + tryParseJson, +} from "./http.mjs"; +import { handleAuth } from "./routes/auth.mjs"; +import { handleConversations } from "./routes/conversations.mjs"; +import { handleCron } from "./routes/cron.mjs"; +import { handleIntegrations } from "./routes/integrations.mjs"; +import { handleInvites } from "./routes/invites.mjs"; +import { handleOAuth } from "./routes/oauth.mjs"; +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 { + appendRequest, + DEFAULT_PORT, + MAX_PORT_RETRY_ATTEMPTS, + openSockets, +} from "./state.mjs"; + +let server = null; + +// Order matters: admin & socket.io short-circuit early; the rest fall through +// in domain order so the cheapest predicates run first. +const ROUTE_HANDLERS = [ + handleOAuth, + handleAuth, + handleUser, + handleInvites, + handlePayments, + handleIntegrations, + handleWebhooks, + handleCron, + handleConversations, + handleVersion, +]; + +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); + + appendRequest({ + method, + url, + body, + headers: normalizeHeaders(req.headers), + timestamp: Date.now(), + }); + + if (method === "OPTIONS") { + setCors(res); + res.writeHead(204); + res.end(); + return; + } + + const ctx = { + method, + url, + body, + parsedBody, + origin, + req, + res, + getPort: getMockServerPort, + }; + + if (handleAdmin(ctx)) return; + + if (url.startsWith("/socket.io/")) { + handleEnginePollingOpen(req, res); + return; + } + + for (const handler of ROUTE_HANDLERS) { + if (await handler(ctx)) return; + } + + // Catch-all: fail fast so tests notice missing mock endpoints. + console.log(`[MockServer] UNHANDLED ${method} ${url}`); + json(res, 404, { + success: false, + error: `Mock server: no handler for ${method} ${url}`, + }); +} + +export function getMockServerPort() { + const address = server?.address(); + return typeof address === "object" && address ? address.port : null; +} + +function createServerInstance() { + const nextServer = http.createServer((req, res) => { + handleRequest(req, res).catch((err) => { + console.error("[MockServer] Unhandled error:", err); + json(res, 500, { success: false, error: "Internal mock error" }); + }); + }); + nextServer.on("connection", (socket) => { + openSockets.add(socket); + socket.on("close", () => openSockets.delete(socket)); + }); + nextServer.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket)); + return nextServer; +} + +function listen(serverInstance, port) { + return new Promise((resolve, reject) => { + const onError = (err) => { + serverInstance.off("listening", onListening); + reject(err); + }; + const onListening = () => { + serverInstance.off("error", onError); + const address = serverInstance.address(); + const resolvedPort = + typeof address === "object" && address ? address.port : port; + resolve(resolvedPort); + }; + serverInstance.once("error", onError); + serverInstance.once("listening", onListening); + serverInstance.listen(port, "127.0.0.1"); + }); +} + +export async function startMockServer(port = DEFAULT_PORT, options = {}) { + if (server) { + return { port: getMockServerPort() ?? port, alreadyRunning: true }; + } + + const preferredPort = + Number.isInteger(port) && port > 0 ? port : DEFAULT_PORT; + const retryIfInUse = options.retryIfInUse === true; + const candidatePorts = retryIfInUse + ? [ + preferredPort, + ...Array.from( + { length: MAX_PORT_RETRY_ATTEMPTS }, + (_, i) => preferredPort + i + 1, + ), + 0, + ] + : [preferredPort]; + + let lastError = null; + for (const candidatePort of candidatePorts) { + const nextServer = createServerInstance(); + try { + const resolvedPort = await listen(nextServer, candidatePort); + server = nextServer; + const retryNote = + resolvedPort === preferredPort + ? "" + : ` (preferred ${preferredPort} unavailable)`; + console.log( + `[MockServer] Listening on http://127.0.0.1:${resolvedPort}${retryNote}`, + ); + return { + port: resolvedPort, + alreadyRunning: false, + requestedPort: preferredPort, + retried: resolvedPort !== preferredPort, + }; + } catch (err) { + try { + nextServer.close(); + } catch { + // The failed candidate may never have reached the listening state. + } + lastError = err; + if (!retryIfInUse || err?.code !== "EADDRINUSE") { + throw err; + } + console.warn( + `[MockServer] Port ${candidatePort} unavailable; trying another local port`, + ); + } + } + + throw lastError ?? new Error("Mock server failed to start"); +} + +export 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(); + }); + }); +} diff --git a/scripts/mock-api/socket.mjs b/scripts/mock-api/socket.mjs new file mode 100644 index 000000000..5503cb25d --- /dev/null +++ b/scripts/mock-api/socket.mjs @@ -0,0 +1,142 @@ +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", () => {}); +} diff --git a/scripts/mock-api/state.mjs b/scripts/mock-api/state.mjs new file mode 100644 index 000000000..850be2977 --- /dev/null +++ b/scripts/mock-api/state.mjs @@ -0,0 +1,154 @@ +import crypto from "node:crypto"; + +export const DEFAULT_PORT = 18473; +export const MOCK_JWT = "e2e-mock-jwt-token"; +export const MAX_PORT_RETRY_ATTEMPTS = 10; + +let requestLog = []; +let mockBehavior = {}; +let mockTunnels = []; + +export const openSockets = new Set(); + +export function getRequestLog() { + return [...requestLog]; +} + +export function clearRequestLog() { + requestLog = []; +} + +export function appendRequest(entry) { + requestLog.push(entry); +} + +export function getMockBehavior() { + return { ...mockBehavior }; +} + +export function setMockBehavior(key, value) { + mockBehavior[key] = String(value); +} + +export function setMockBehaviors(behavior, mode = "merge") { + if (mode === "replace") { + mockBehavior = {}; + } + for (const [key, value] of Object.entries(behavior || {})) { + mockBehavior[key] = String(value); + } +} + +export function resetMockBehavior() { + mockBehavior = {}; +} + +export function behavior() { + return mockBehavior; +} + +export function parseBehaviorJson(key, fallback) { + const raw = mockBehavior[key]; + if (!raw) return JSON.parse(JSON.stringify(fallback)); + try { + return JSON.parse(raw); + } catch { + return JSON.parse(JSON.stringify(fallback)); + } +} + +export function getDelayMs(key) { + const value = Number(mockBehavior[key] || 0); + return Number.isFinite(value) && value > 0 ? value : 0; +} + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function getMockTunnels() { + return mockTunnels; +} + +export function setMockTunnels(next) { + mockTunnels = Array.isArray(next) ? next : []; +} + +export function resetMockTunnels() { + mockTunnels = []; +} + +export function createMockTunnel(payload = {}) { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + uuid: crypto.randomUUID(), + name: String(payload.name || "Mock Tunnel").trim(), + description: String(payload.description || "").trim(), + isActive: payload.isActive ?? true, + createdAt: now, + updatedAt: now, + }; +} + +export 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, + remainingUsd: 10, + spentThisCycleUsd: 0, + spentTodayUsd: 0, + cycleStartDate: new Date().toISOString(), + }, + autoDeleteTelegramMessagesAfterDays: 30, + autoDeleteThreadsAfterDays: 30, + }; +} + +export 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", + }; +} diff --git a/scripts/review/README.md b/scripts/review/README.md index 6ab1481f5..d207828e8 100644 --- a/scripts/review/README.md +++ b/scripts/review/README.md @@ -8,13 +8,14 @@ integration needed. | `sync.sh` | Fetch PR head, check out as `pr/`, merge `main`, wire push/upstream. | | `review.sh` | `sync` + hand off to the `pr-reviewer` agent to review, comment, and approve. | | `fix.sh` | `sync` + `pr-reviewer` (apply fixes) + `pr-manager-lite` (commit & push). | +| `coverage.sh`| `sync` + gather coverage CI context + agent to fix coverage, push, babysit checks.| | `merge.sh` | LLM-summarized squash body + filtered Co-authored-by trailers + `gh pr merge`. | ## LLM flags -- `review` / `fix`: `--agent ` (default `claude`). Picks the CLI that - drives the agent prompt. An optional trailing positional `` is - appended verbatim to the agent's prompt (e.g. +- `review` / `fix` / `coverage`: `--agent ` (default `claude`). Picks the + CLI that drives the agent prompt. An optional trailing positional + `` is appended verbatim to the agent's prompt (e.g. `pnpm review fix 123 "focus on the retry logic"`). - `merge`: `--summary-llm ` (default `gemini`). The LLM that condenses the PR body + commit messages into a concise squash commit body. Use `--summary-llm none` @@ -30,6 +31,7 @@ Via pnpm (preferred): pnpm review sync 123 pnpm review review 123 pnpm review fix 123 +pnpm review coverage 123 pnpm review merge 123 # --squash pnpm review merge 123 --rebase pnpm review --help @@ -41,6 +43,7 @@ Or invoke the scripts directly: scripts/review/sync.sh 123 scripts/review/review.sh 123 scripts/review/fix.sh 123 +scripts/review/coverage.sh 123 scripts/review/merge.sh 123 ``` @@ -52,6 +55,6 @@ scripts/review/merge.sh 123 `Co-authored-by:` entries (default filters copilot / codex / cursor / claude / anthropic / openai / chatgpt / `[bot]` / `noreply@github` / `users.noreply.github.com`; matched case-insensitively on name or email). -- Requires `git`, `gh`, `jq`. `review` / `fix` also require the agent CLI - (default `claude`); `merge` also requires the summary LLM CLI (default `gemini`) - unless `--summary-llm none`. +- Requires `git`, `gh`, `jq`. `review` / `fix` / `coverage` also require the + agent CLI (default `claude`); `merge` also requires the summary LLM CLI + (default `gemini`) unless `--summary-llm none`. diff --git a/scripts/review/cli.sh b/scripts/review/cli.sh index 38f2d2adb..a51829f71 100755 --- a/scripts/review/cli.sh +++ b/scripts/review/cli.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Dispatcher for `pnpm review `. -# Commands: sync | review | fix | merge +# Commands: sync | review | fix | coverage | merge set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -19,6 +19,11 @@ Commands: Sync + pr-reviewer (apply fixes) + pr-manager-lite (push) Default agent: claude Trailing extra-prompt is appended to the agent prompt. + coverage [--agent ] [extra-prompt] + Sync + gather coverage CI context + agent to fix coverage + failures, improve coverage, push, and babysit the PR + Default agent: claude + Trailing extra-prompt is appended to the agent prompt. merge [--squash|--merge|--rebase] [--dry-run] [--force] [--admin|--auto] [--summary-llm ] Merge via gh (default --squash, deletes branch). Requires reviewDecision=APPROVED and green required checks @@ -43,7 +48,7 @@ fi shift case "$cmd" in - sync|review|fix|merge) + sync|review|fix|coverage|merge) exec "$here/${cmd}.sh" "$@" ;; *) diff --git a/scripts/review/coverage.sh b/scripts/review/coverage.sh new file mode 100644 index 000000000..912f2ed6b --- /dev/null +++ b/scripts/review/coverage.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# coverage.sh [--agent ] [extra-prompt] +# Sync the PR locally, gather coverage-related GitHub Actions context, then hand +# off to an agent to fix coverage gate failures, improve coverage, and babysit +# the PR until the relevant checks are green or the work is clearly blocked. +# +# --agent picks the CLI that drives the work. Default: claude. +# A trailing positional (any free-form text) is appended to the +# agent's prompt verbatim. + +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$here/lib.sh" + +require git gh jq +require_pr_number "${1:-}" + +pr="$1" +agent="claude" +extra_prompt="" +shift +while [ $# -gt 0 ]; do + case "$1" in + --agent) agent="${2:?--agent requires a value}"; shift 2 ;; + --agent=*) agent="${1#*=}"; shift ;; + *) + if [ -n "$extra_prompt" ]; then + echo "[review] unexpected extra arg: $1 (extra-prompt already set)" >&2 + exit 1 + fi + extra_prompt="$1"; shift + ;; + esac +done + +require "$agent" +sync_pr "$pr" + +coverage_checks=$(gh pr checks "$REVIEW_PR" -R "$REVIEW_REPO_RESOLVED" 2>/dev/null \ + | grep -i 'coverage' || true) + +coverage_runs_json=$(gh run list -R "$REVIEW_REPO_RESOLVED" \ + --workflow "Coverage Gate" \ + --branch "$REVIEW_HEAD_BRANCH" \ + --limit 5 \ + --json databaseId,status,conclusion,url,createdAt,updatedAt,headSha || echo '[]') + +coverage_runs_summary=$(printf '%s\n' "$coverage_runs_json" | jq -r ' + if length == 0 then + "No recent Coverage Gate workflow runs found for this branch." + else + .[] | "- run=\(.databaseId) status=\(.status) conclusion=\(.conclusion // "n/a") sha=\(.headSha) updated=\(.updatedAt) url=\(.url)" + end +') + +if [ -z "$coverage_checks" ]; then + coverage_checks="No current coverage-related checks were returned by gh pr checks." +fi + +prompt="I've already checked out branch pr/$REVIEW_PR with main \ +merged in and upstream tracking set (repo: $REVIEW_REPO_RESOLVED). Focus on the \ +coverage gate for PR #$REVIEW_PR. + +Current coverage-related PR checks: +$coverage_checks + +Recent Coverage Gate workflow runs for this branch: +$coverage_runs_summary + +Use the GitHub Actions error output for the coverage jobs to identify what is \ +failing. Fix the coverage workflow or scripts if they are broken, improve test \ +coverage as needed to satisfy the gate, run the relevant local checks, and push \ +the fixes back to the PR branch. + +After pushing, babysit the PR: monitor the coverage and related required checks, \ +investigate any new failures, and keep iterating until the coverage gate is \ +green or you hit a real blocker. If you are blocked, summarize the blocker \ +clearly with the failing check name, exact error, and what remains to fix." + +if [ -n "$extra_prompt" ]; then + prompt="${prompt} + +Additional instructions from the user: +${extra_prompt}" +fi + +"$agent" "$prompt"