mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
refactor(mock-api): domain split + OAuth + Composio + e2e workflow + macOS mega-spec scaffold (#1532)
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
+26
-11
@@ -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."
|
||||
|
||||
+86
-59
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, string> {
|
||||
const headers: Record<string, string> = { '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<boolean> {
|
||||
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<T = unknown>(
|
||||
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();
|
||||
|
||||
@@ -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<void> {
|
||||
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`);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>[] {
|
||||
if (process.platform === 'linux') {
|
||||
if (process.platform === 'linux' || process.platform === 'win32') {
|
||||
return [{ 'tauri:options': { application: getAppPath() } }];
|
||||
}
|
||||
|
||||
@@ -77,8 +79,8 @@ function getPlatformCapabilities(): Record<string, unknown>[] {
|
||||
}
|
||||
|
||||
/** 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<string, unknown> = {
|
||||
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'],
|
||||
|
||||
@@ -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 <owner/name> Repository to inspect (default: ${DEFAULT_REPO})
|
||||
--max-age-minutes <minutes> Cancel runs older than this (default: ${DEFAULT_MAX_AGE_MINUTES})
|
||||
--open-pr-limit <count> Max open PRs to inspect (default: ${DEFAULT_OPEN_PR_LIMIT})
|
||||
--exclude-workflow <pattern> 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();
|
||||
@@ -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 <owner/name> Repository to inspect (default: ${DEFAULT_REPO})
|
||||
--base-branch <name> Base branch to merge into PRs (default: ${DEFAULT_BASE_BRANCH})
|
||||
--open-pr-limit <count> Max open PRs to inspect (default: ${DEFAULT_OPEN_PR_LIMIT})
|
||||
--pr <number> 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();
|
||||
+9
-1504
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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 ?? ""),
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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/<provider>, and the legacy
|
||||
// /mock-<provider>-oauth aliases are handled by routes/oauth.mjs, which
|
||||
// actually completes the OAuth flow via deep links.
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/<provider>/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/<provider>/connect`
|
||||
* already points the app at `${origin}/mock-<provider>-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-<provider>-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=<name> — overrides provider inferred from the path.
|
||||
* ?integrationId=<id> — overrides the integrationId emitted.
|
||||
* ?manual=1 — disable auto-redirect (test wants to click).
|
||||
* ?error=<code> — emit an error deep link instead of success.
|
||||
*/
|
||||
export function handleOAuth(ctx) {
|
||||
const { method, url, parsedBody, res } = ctx;
|
||||
|
||||
// /mock-oauth/<provider> and the legacy /mock-<provider>-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
|
||||
? ""
|
||||
: `<script>setTimeout(function(){window.location.href=${JSON.stringify(
|
||||
target,
|
||||
)};}, ${Number(autoRedirectMs)});</script>`;
|
||||
|
||||
const metaRefresh =
|
||||
autoRedirectMs === null
|
||||
? ""
|
||||
: `<meta http-equiv="refresh" content="${(Number(autoRedirectMs) /
|
||||
1000).toFixed(2)};url=${safeTarget}" />`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Mock OAuth · ${safeProvider}</title>
|
||||
${metaRefresh}
|
||||
<style>
|
||||
body { font: 16px/1.4 system-ui, sans-serif; max-width: 480px; margin: 80px auto; padding: 0 24px; color: #1d1d1d; }
|
||||
h1 { font-size: 20px; margin: 0 0 12px; }
|
||||
p { margin: 0 0 16px; color: #555; }
|
||||
a.button { display: inline-block; padding: 10px 16px; background: #4A83DD; color: #fff; border-radius: 8px; text-decoration: none; font-weight: 600; }
|
||||
code { background: #f3f3f3; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${heading}</h1>
|
||||
<p>${blurb}</p>
|
||||
<p>Target: <code>${safeTarget}</code></p>
|
||||
<p><a class="button" id="continue" href="${safeTarget}">Continue to OpenHuman</a></p>
|
||||
${autoRedirectScript}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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", () => {});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -8,13 +8,14 @@ integration needed.
|
||||
| `sync.sh` | Fetch PR head, check out as `pr/<num>`, 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 <tool>` (default `claude`). Picks the CLI that
|
||||
drives the agent prompt. An optional trailing positional `<extra-prompt>` is
|
||||
appended verbatim to the agent's prompt (e.g.
|
||||
- `review` / `fix` / `coverage`: `--agent <tool>` (default `claude`). Picks the
|
||||
CLI that drives the agent prompt. An optional trailing positional
|
||||
`<extra-prompt>` is appended verbatim to the agent's prompt (e.g.
|
||||
`pnpm review fix 123 "focus on the retry logic"`).
|
||||
- `merge`: `--summary-llm <tool>` (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`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dispatcher for `pnpm review <cmd> <args…>`.
|
||||
# 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 <pr> [--agent <tool>] [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 <pr> [--squash|--merge|--rebase] [--dry-run] [--force] [--admin|--auto] [--summary-llm <tool>]
|
||||
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" "$@"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# coverage.sh <pr-number> [--agent <tool>] [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 <extra-prompt> (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"
|
||||
Reference in New Issue
Block a user