refactor(mock-api): domain split + OAuth + Composio + e2e workflow + macOS mega-spec scaffold (#1532)

This commit is contained in:
Steven Enamakel
2026-05-13 08:43:03 -07:00
committed by GitHub
parent 1d37d2dfca
commit 9694dbee3c
30 changed files with 3669 additions and 1591 deletions
+245
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+38
View File
@@ -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(
+41 -2
View File
@@ -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();
+288
View File
@@ -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`);
});
});
+9 -7
View File
@@ -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'],
+256
View File
@@ -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();
+382
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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;
}
+74
View File
@@ -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 ?? ""),
];
}),
);
}
+20
View File
@@ -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";
+145
View File
@@ -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;
}
+102
View File
@@ -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;
}
+63
View File
@@ -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;
}
+293
View File
@@ -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;
}
+26
View File
@@ -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;
}
+228
View File
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[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>`;
}
+262
View File
@@ -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;
}
+298
View File
@@ -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,
};
}
}
+22
View File
@@ -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;
}
+89
View File
@@ -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;
}
+211
View File
@@ -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();
});
});
}
+142
View File
@@ -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", () => {});
}
+154
View File
@@ -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",
};
}
+9 -6
View File
@@ -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`.
+7 -2
View File
@@ -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" "$@"
;;
*)
+88
View File
@@ -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"