mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Merge remote-tracking branch 'upstream/develop' into develop
This commit is contained in:
@@ -40,3 +40,9 @@ src-tauri/runtime-skill-*
|
||||
.cargo
|
||||
|
||||
CLAUDE.local.md
|
||||
|
||||
# Test artifacts
|
||||
e2e-results/
|
||||
wdio-logs/
|
||||
test-results/
|
||||
coverage/
|
||||
|
||||
@@ -20,8 +20,15 @@
|
||||
"android:dev": "tauri android dev",
|
||||
"android:build": "tauri android build",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
|
||||
"test:e2e:build": "yarn macos:build:debug",
|
||||
"test:e2e:run": "bash scripts/e2e.sh",
|
||||
"test:e2e": "yarn test:e2e:build && yarn test:e2e:run",
|
||||
"test:all": "yarn test:coverage && yarn test:rust && yarn test:e2e",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
@@ -62,6 +69,10 @@
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^25.0.10",
|
||||
@@ -72,6 +83,11 @@
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@wdio/appium-service": "^9.24.0",
|
||||
"@wdio/cli": "^9.24.0",
|
||||
"@wdio/local-runner": "^9.24.0",
|
||||
"@wdio/mocha-framework": "^9.24.0",
|
||||
"@wdio/spec-reporter": "^9.24.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -79,6 +95,8 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.0.0",
|
||||
"msw": "^2.12.10",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Start Appium under Node 24 (required by appium v3), run WebDriverIO E2E
|
||||
# tests, then tear everything down.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/e2e.sh # run tests
|
||||
# APPIUM_PORT=4723 ./scripts/e2e.sh # custom port
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
APPIUM_PORT="${APPIUM_PORT:-4723}"
|
||||
|
||||
# --- Resolve Node 24 via nvm ---------------------------------------------------
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
|
||||
NODE24="$(nvm which 24 2>/dev/null || true)"
|
||||
if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then
|
||||
echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APPIUM_BIN="$(dirname "$NODE24")/appium"
|
||||
if [ ! -x "$APPIUM_BIN" ]; then
|
||||
echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Start Appium in the background -------------------------------------------
|
||||
NODE_VER=$("$NODE24" --version)
|
||||
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
|
||||
"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security &
|
||||
APPIUM_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "Stopping Appium (pid $APPIUM_PID)..."
|
||||
kill "$APPIUM_PID" 2>/dev/null || true
|
||||
wait "$APPIUM_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Wait for Appium to be ready
|
||||
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
|
||||
|
||||
# --- Run WebDriverIO ----------------------------------------------------------
|
||||
echo "Running E2E tests..."
|
||||
npx wdio run wdio.conf.ts
|
||||
+1
-1
Submodule skills updated: 9ed6851a11...0c6a11b1e0
@@ -362,6 +362,26 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Start the TDLib client early so it's ready before any skill
|
||||
// starts. The worker loop auto-sends setTdlibParameters when
|
||||
// TDLib requests them — no JS involvement needed.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.unwrap_or_else(|_| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".alphahuman")
|
||||
});
|
||||
let tdlib_data_dir = data_dir.join("telegram");
|
||||
match crate::services::tdlib::TDLIB_MANAGER.create_client(tdlib_data_dir) {
|
||||
Ok(id) => log::info!("[tdlib] Client created at app startup (ID {})", id),
|
||||
Err(e) => log::error!("[tdlib] Failed to create client at startup: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Create the SocketManager (persistent Rust-native Socket.io)
|
||||
let socket_mgr = std::sync::Arc::new(
|
||||
runtime::socket_manager::SocketManager::new(),
|
||||
|
||||
@@ -941,6 +941,16 @@ globalThis.tdlib = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create client and set TDLib parameters in a single atomic call.
|
||||
* API credentials are stored on the Rust side only.
|
||||
* @param {string} dataDir - Path to store TDLib data files.
|
||||
* @returns {Promise<number>} Client ID.
|
||||
*/
|
||||
ensureInitialized: async function (dataDir) {
|
||||
return await __ops.tdlib_ensure_initialized(dataDir);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a TDLib client with the given data directory.
|
||||
* @param {string} dataDir - Path to store TDLib data files.
|
||||
|
||||
@@ -13,6 +13,31 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, skill_context: SkillCont
|
||||
))?;
|
||||
}
|
||||
|
||||
{
|
||||
let sc = skill_context.clone();
|
||||
ops.set("tdlib_ensure_initialized", Function::new(ctx.clone(),
|
||||
Async(move |data_dir: String| {
|
||||
let skill_id = sc.skill_id.clone();
|
||||
async move {
|
||||
log::info!("[tdlib_v8] ensure_initialized with data_dir: {}", data_dir);
|
||||
check_telegram_skill(&skill_id).map_err(|e| {
|
||||
log::error!("[tdlib_v8] Skill check failed: {}", e);
|
||||
js_err(e)
|
||||
})?;
|
||||
let client_id = crate::services::tdlib::TDLIB_MANAGER
|
||||
.ensure_initialized(PathBuf::from(data_dir))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("[tdlib_v8] ensure_initialized failed: {}", e);
|
||||
js_err(e)
|
||||
})?;
|
||||
log::info!("[tdlib_v8] ensure_initialized succeeded with client ID: {}", client_id);
|
||||
Ok::<i32, rquickjs::Error>(client_id)
|
||||
}
|
||||
}),
|
||||
))?;
|
||||
}
|
||||
|
||||
{
|
||||
let sc = skill_context.clone();
|
||||
ops.set("tdlib_create_client", Function::new(ctx.clone(),
|
||||
|
||||
@@ -16,6 +16,10 @@ use once_cell::sync::Lazy;
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
/// Telegram API credentials (kept on the Rust side only).
|
||||
const API_ID: i32 = 28685916;
|
||||
const API_HASH: &str = "d540ab21dece5404af298c44f4f6386d";
|
||||
|
||||
/// Global TDLib manager instance.
|
||||
pub static TDLIB_MANAGER: Lazy<TdLibManager> = Lazy::new(TdLibManager::new);
|
||||
|
||||
@@ -122,6 +126,7 @@ impl TdLibManager {
|
||||
/// Returns the client ID. If a client already exists, returns its ID.
|
||||
/// Only one client can exist at a time; blocks if a destroy is in progress.
|
||||
pub fn create_client(&self, data_dir: PathBuf) -> Result<i32, String> {
|
||||
log::info!("[tdlib] create_client called with data dir: {:?}", data_dir);
|
||||
// Block creation while a destroy is in progress to prevent
|
||||
// creating a new C++ client before the old one releases its database lock.
|
||||
if self.is_destroying.load(Ordering::SeqCst) {
|
||||
@@ -143,7 +148,8 @@ impl TdLibManager {
|
||||
// Create TDLib client using tdlib_rs
|
||||
let client_id = tdlib_rs::create_client();
|
||||
|
||||
// Store data directory
|
||||
// Store data directory and pass a clone to the worker
|
||||
let worker_data_dir = data_dir.clone();
|
||||
*self.data_dir.write() = Some(data_dir);
|
||||
|
||||
// Create request channel
|
||||
@@ -158,7 +164,7 @@ impl TdLibManager {
|
||||
let cid = client_id;
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
Self::worker_loop(cid, state, request_rx);
|
||||
Self::worker_loop(cid, state, request_rx, worker_data_dir);
|
||||
});
|
||||
*self.worker_handle.write() = Some(handle);
|
||||
|
||||
@@ -170,7 +176,7 @@ impl TdLibManager {
|
||||
let tdlib_verbosity: i32 = std::env::var("TDLIB_LOG_LEVEL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(1);
|
||||
let cid_for_log = client_id;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = tdlib_rs::functions::set_log_verbosity_level(tdlib_verbosity, cid_for_log).await {
|
||||
@@ -181,11 +187,71 @@ impl TdLibManager {
|
||||
Ok(client_id)
|
||||
}
|
||||
|
||||
/// Create the client (if needed) and return the client ID.
|
||||
///
|
||||
/// `setTdlibParameters` is sent **reactively** by the worker loop whenever
|
||||
/// TDLib reports `authorizationStateWaitTdlibParameters`, so callers don't
|
||||
/// need to send it themselves.
|
||||
pub async fn ensure_initialized(&self, data_dir: PathBuf) -> Result<i32, String> {
|
||||
self.create_client(data_dir)
|
||||
}
|
||||
|
||||
pub async fn send_set_tdlib_parameters(&self) -> Result<(), String> {
|
||||
// Resolve client_id from manager state
|
||||
let client_id_opt = *self.client_id.read();
|
||||
let client_id = match client_id_opt {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
log::error!("[tdlib] Cannot send setTdlibParameters: client_id not initialized");
|
||||
return Err("TDLib client is not initialized".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve data_dir from manager state
|
||||
let data_dir_opt = self.data_dir.read().clone();
|
||||
let data_dir = match data_dir_opt {
|
||||
Some(dir) => dir,
|
||||
None => {
|
||||
log::error!("[tdlib] Cannot send setTdlibParameters: data_dir not set");
|
||||
return Err("TDLib data directory is not set".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("[tdlib] Sending setTdlibParameters to client {}", client_id);
|
||||
let db_dir = data_dir.to_string_lossy().to_string();
|
||||
let files_dir = data_dir.join("files").to_string_lossy().to_string();
|
||||
let params = serde_json::json!({
|
||||
"@type": "setTdlibParameters",
|
||||
"use_test_dc": false,
|
||||
"database_directory": db_dir,
|
||||
"files_directory": files_dir,
|
||||
"database_encryption_key": "",
|
||||
"use_file_database": true,
|
||||
"use_chat_info_database": true,
|
||||
"use_message_database": true,
|
||||
"use_secret_chats": false,
|
||||
"api_id": API_ID,
|
||||
"api_hash": API_HASH,
|
||||
"system_language_code": "en",
|
||||
"device_model": "Desktop",
|
||||
"system_version": "",
|
||||
"application_version": "1.0.0"
|
||||
});
|
||||
match Self::send_json_request(client_id, ¶ms).await {
|
||||
Err(e) => {
|
||||
log::error!("[tdlib] Auto-send setTdlibParameters failed: {}", e);
|
||||
Err(format!("Failed to send setTdlibParameters: {}", e))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker loop that handles requests and polls for updates.
|
||||
fn worker_loop(
|
||||
client_id: i32,
|
||||
state: Arc<ClientState>,
|
||||
mut request_rx: mpsc::Receiver<TdRequest>,
|
||||
data_dir: PathBuf,
|
||||
) {
|
||||
log::info!("[tdlib] Worker loop started for client {}", client_id);
|
||||
|
||||
@@ -271,6 +337,26 @@ impl TdLibManager {
|
||||
// Convert update to JSON for processing
|
||||
let json = serde_json::to_value(&update).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
log::info!("[tdlib] Handling response: {}", serde_json::to_string_pretty(&json).unwrap_or_else(|_| "invalid JSON".to_string()));
|
||||
|
||||
// React to authorizationStateWaitTdlibParameters — auto-send
|
||||
// setTdlibParameters with the configured credentials.
|
||||
if let Some(auth_type) = json
|
||||
.get("authorization_state")
|
||||
.and_then(|s| s.get("@type"))
|
||||
.and_then(|t| t.as_str())
|
||||
{
|
||||
if auth_type == "authorizationStateWaitTdlibParameters" {
|
||||
log::info!("[tdlib] TDLib requests parameters — scheduling auto-send");
|
||||
tauri::async_runtime::spawn(async {
|
||||
if let Err(e) = TDLIB_MANAGER.send_set_tdlib_parameters().await {
|
||||
log::error!("[tdlib] Auto-send setTdlibParameters failed: {}", e);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a response to a pending request (has @extra)
|
||||
if let Some(extra) = json.get("@extra").and_then(|v| v.as_str()) {
|
||||
// Find and complete the pending request
|
||||
@@ -558,6 +644,7 @@ impl TdLibManager {
|
||||
|
||||
/// Receive the next update from TDLib (with timeout).
|
||||
pub async fn receive(&self, timeout_ms: u32) -> Option<serde_json::Value> {
|
||||
log::info!("[tdlib] receive called with timeout: {}ms", timeout_ms);
|
||||
let request_tx = {
|
||||
self.request_tx.read().clone()
|
||||
}?;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ConnectionIndicator from "../ConnectionIndicator";
|
||||
import { renderWithProviders } from "../../test/test-utils";
|
||||
|
||||
describe("ConnectionIndicator", () => {
|
||||
it("renders connected state with override prop", () => {
|
||||
renderWithProviders(<ConnectionIndicator status="connected" />);
|
||||
expect(
|
||||
screen.getByText(/Connected to AlphaHuman AI/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disconnected state", () => {
|
||||
renderWithProviders(<ConnectionIndicator status="disconnected" />);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders connecting state", () => {
|
||||
renderWithProviders(<ConnectionIndicator status="connecting" />);
|
||||
expect(screen.getByText("Connecting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description text when provided", () => {
|
||||
renderWithProviders(
|
||||
<ConnectionIndicator
|
||||
status="connected"
|
||||
description="Custom description"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Custom description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render description when empty string", () => {
|
||||
renderWithProviders(
|
||||
<ConnectionIndicator status="connected" description="" />
|
||||
);
|
||||
// Default description should not appear
|
||||
expect(
|
||||
screen.queryByText(/Keep the app running/)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to store socket status when no override", () => {
|
||||
// Default store state has no socket connection → disconnected
|
||||
renderWithProviders(<ConnectionIndicator />);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import ProtectedRoute from "../ProtectedRoute";
|
||||
import { renderWithProviders } from "../../test/test-utils";
|
||||
|
||||
describe("ProtectedRoute", () => {
|
||||
it("renders children when token exists", () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>Protected Content</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: "valid-jwt",
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Protected Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to / when no token and requireAuth=true", () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>Dashboard</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<div>Landing</div>} />
|
||||
</Routes>,
|
||||
{
|
||||
initialEntries: ["/dashboard"],
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: null,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Dashboard")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Landing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to custom redirectTo when no token", () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute redirectTo="/login">
|
||||
<div>Dashboard</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>,
|
||||
{
|
||||
initialEntries: ["/dashboard"],
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: null,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Login Page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to /onboarding when requireOnboarded but not onboarded", () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/home"
|
||||
element={
|
||||
<ProtectedRoute requireOnboarded>
|
||||
<div>Home Content</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/onboarding" element={<div>Onboarding</div>} />
|
||||
</Routes>,
|
||||
{
|
||||
initialEntries: ["/home"],
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: "valid-jwt",
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
user: { user: { _id: "u1" }, isLoading: false, error: null },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Onboarding")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import PublicRoute from "../PublicRoute";
|
||||
import { renderWithProviders } from "../../test/test-utils";
|
||||
|
||||
describe("PublicRoute", () => {
|
||||
it("renders children when user is not authenticated", () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<div>Welcome Page</div>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: null,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Welcome Page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to /home when user is authenticated", () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<div>Welcome Page</div>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/home" element={<div>Home</div>} />
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: "jwt-token",
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Welcome Page")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Home")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to custom path when authenticated", () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PublicRoute redirectTo="/dashboard">
|
||||
<div>Welcome Page</div>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/dashboard" element={<div>Dashboard</div>} />
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: "jwt-token",
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { server } from "../../../test/server";
|
||||
|
||||
// Mock the store import that apiClient depends on
|
||||
vi.mock("../../../store", () => ({
|
||||
store: {
|
||||
getState: () => ({
|
||||
auth: { token: "test-jwt-token" },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the config to use test backend URL
|
||||
vi.mock("../../../utils/config", () => ({
|
||||
BACKEND_URL: "http://localhost:5005",
|
||||
IS_DEV: true,
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
const { userApi } = await import("../userApi");
|
||||
|
||||
describe("userApi.getMe", () => {
|
||||
it("returns user data on success", async () => {
|
||||
// Default handler from handlers.ts already handles this
|
||||
const user = await userApi.getMe();
|
||||
expect(user._id).toBe("user-123");
|
||||
expect(user.firstName).toBe("Test");
|
||||
expect(user.username).toBe("testuser");
|
||||
expect(user.subscription.plan).toBe("FREE");
|
||||
});
|
||||
|
||||
it("throws when API returns error response", async () => {
|
||||
server.use(
|
||||
http.get("http://localhost:5005/telegram/me", () => {
|
||||
return HttpResponse.json(
|
||||
{ success: false, error: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(userApi.getMe()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws when API returns success=false", async () => {
|
||||
server.use(
|
||||
http.get("http://localhost:5005/telegram/me", () => {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: "Invalid token",
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await expect(userApi.getMe()).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
it("throws on network error", async () => {
|
||||
server.use(
|
||||
http.get("http://localhost:5005/telegram/me", () => {
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
|
||||
await expect(userApi.getMe()).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectIsOnboarded } from "../authSelectors";
|
||||
import type { RootState } from "../index";
|
||||
|
||||
function makeState(
|
||||
overrides: Partial<{
|
||||
token: string | null;
|
||||
userId: string | undefined;
|
||||
isOnboardedByUser: Record<string, boolean>;
|
||||
}> = {}
|
||||
): RootState {
|
||||
const {
|
||||
token = null,
|
||||
userId,
|
||||
isOnboardedByUser = {},
|
||||
} = overrides;
|
||||
|
||||
return {
|
||||
auth: {
|
||||
token,
|
||||
isOnboardedByUser,
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
user: {
|
||||
user: userId
|
||||
? ({ _id: userId } as RootState["user"]["user"])
|
||||
: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
socket: { byUser: {} },
|
||||
team: {} as RootState["team"],
|
||||
ai: {} as RootState["ai"],
|
||||
skills: {} as RootState["skills"],
|
||||
} as RootState;
|
||||
}
|
||||
|
||||
describe("selectIsOnboarded", () => {
|
||||
it("returns false when no user is loaded", () => {
|
||||
const state = makeState();
|
||||
expect(selectIsOnboarded(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user exists but is not onboarded", () => {
|
||||
const state = makeState({ userId: "u1" });
|
||||
expect(selectIsOnboarded(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when user is onboarded", () => {
|
||||
const state = makeState({
|
||||
userId: "u1",
|
||||
isOnboardedByUser: { u1: true },
|
||||
});
|
||||
expect(selectIsOnboarded(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a different user id", () => {
|
||||
const state = makeState({
|
||||
userId: "u2",
|
||||
isOnboardedByUser: { u1: true },
|
||||
});
|
||||
expect(selectIsOnboarded(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import authReducer, {
|
||||
setAnalyticsForUser,
|
||||
setOnboardedForUser,
|
||||
setToken,
|
||||
} from "../authSlice";
|
||||
import teamReducer from "../teamSlice";
|
||||
import userReducer from "../userSlice";
|
||||
|
||||
function createStore(preloaded?: Record<string, unknown>) {
|
||||
return configureStore({
|
||||
// Cast reducer map to any to avoid strict typing issues in this lightweight test harness.
|
||||
reducer: ({ auth: authReducer, user: userReducer, team: teamReducer } as unknown) as any,
|
||||
preloadedState: preloaded as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe("authSlice", () => {
|
||||
it("has correct initial state", () => {
|
||||
const store = createStore();
|
||||
const state = store.getState().auth;
|
||||
expect(state.token).toBeNull();
|
||||
expect(state.isOnboardedByUser).toEqual({});
|
||||
expect(state.isAnalyticsEnabledByUser).toEqual({});
|
||||
});
|
||||
|
||||
it("sets token via setToken", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setToken("jwt-abc-123"));
|
||||
expect(store.getState().auth.token).toBe("jwt-abc-123");
|
||||
});
|
||||
|
||||
it("sets onboarded flag per user", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setOnboardedForUser({ userId: "u1", value: true }));
|
||||
expect(store.getState().auth.isOnboardedByUser.u1).toBe(true);
|
||||
|
||||
store.dispatch(setOnboardedForUser({ userId: "u2", value: false }));
|
||||
expect(store.getState().auth.isOnboardedByUser.u2).toBe(false);
|
||||
// u1 should be unaffected
|
||||
expect(store.getState().auth.isOnboardedByUser.u1).toBe(true);
|
||||
});
|
||||
|
||||
it("sets analytics consent per user", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setAnalyticsForUser({ userId: "u1", enabled: true }));
|
||||
expect(store.getState().auth.isAnalyticsEnabledByUser.u1).toBe(true);
|
||||
|
||||
store.dispatch(setAnalyticsForUser({ userId: "u1", enabled: false }));
|
||||
expect(store.getState().auth.isAnalyticsEnabledByUser.u1).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectSocketId, selectSocketStatus } from "../socketSelectors";
|
||||
import type { RootState } from "../index";
|
||||
|
||||
// Create a mock JWT with payload { tgUserId: "tg-user-1" }
|
||||
function encodeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const body = btoa(JSON.stringify(payload));
|
||||
return `${header}.${body}.signature`;
|
||||
}
|
||||
|
||||
function makeState(
|
||||
token: string | null,
|
||||
byUser: Record<string, { status: string; socketId: string | null }> = {}
|
||||
): RootState {
|
||||
return {
|
||||
auth: {
|
||||
token,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
socket: { byUser },
|
||||
user: { user: null, isLoading: false, error: null },
|
||||
team: {} as RootState["team"],
|
||||
ai: {} as RootState["ai"],
|
||||
skills: {} as RootState["skills"],
|
||||
} as RootState;
|
||||
}
|
||||
|
||||
describe("selectSocketStatus", () => {
|
||||
it("returns disconnected when no token", () => {
|
||||
const state = makeState(null);
|
||||
expect(selectSocketStatus(state)).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("returns status from user state based on JWT tgUserId", () => {
|
||||
const token = encodeJwt({ tgUserId: "tg123" });
|
||||
const state = makeState(token, {
|
||||
tg123: { status: "connected", socketId: "sock-1" },
|
||||
});
|
||||
expect(selectSocketStatus(state)).toBe("connected");
|
||||
});
|
||||
|
||||
it("returns disconnected when JWT user has no socket state", () => {
|
||||
const token = encodeJwt({ tgUserId: "tg123" });
|
||||
const state = makeState(token, {});
|
||||
expect(selectSocketStatus(state)).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("uses __pending__ for invalid JWT", () => {
|
||||
const state = makeState("not-a-jwt", {
|
||||
__pending__: { status: "connecting", socketId: null },
|
||||
});
|
||||
expect(selectSocketStatus(state)).toBe("connecting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectSocketId", () => {
|
||||
it("returns null when no token", () => {
|
||||
const state = makeState(null);
|
||||
expect(selectSocketId(state)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns socketId from user state", () => {
|
||||
const token = encodeJwt({ tgUserId: "tg123" });
|
||||
const state = makeState(token, {
|
||||
tg123: { status: "connected", socketId: "sock-abc" },
|
||||
});
|
||||
expect(selectSocketId(state)).toBe("sock-abc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import socketReducer, {
|
||||
resetForUser,
|
||||
setSocketIdForUser,
|
||||
setStatusForUser,
|
||||
} from "../socketSlice";
|
||||
|
||||
function createStore() {
|
||||
return configureStore({ reducer: { socket: socketReducer } });
|
||||
}
|
||||
|
||||
describe("socketSlice", () => {
|
||||
it("starts with empty byUser map", () => {
|
||||
const store = createStore();
|
||||
expect(store.getState().socket.byUser).toEqual({});
|
||||
});
|
||||
|
||||
it("sets status for a user", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "connecting" })
|
||||
);
|
||||
expect(store.getState().socket.byUser.u1.status).toBe("connecting");
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBeNull();
|
||||
});
|
||||
|
||||
it("clears socketId when status is disconnected", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "connected" })
|
||||
);
|
||||
store.dispatch(
|
||||
setSocketIdForUser({ userId: "u1", socketId: "sock-123" })
|
||||
);
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBe("sock-123");
|
||||
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "disconnected" })
|
||||
);
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBeNull();
|
||||
});
|
||||
|
||||
it("sets socketId for a user", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setSocketIdForUser({ userId: "u1", socketId: "sock-abc" })
|
||||
);
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBe("sock-abc");
|
||||
});
|
||||
|
||||
it("resets user state", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "connected" })
|
||||
);
|
||||
store.dispatch(
|
||||
setSocketIdForUser({ userId: "u1", socketId: "sock-123" })
|
||||
);
|
||||
store.dispatch(resetForUser({ userId: "u1" }));
|
||||
|
||||
expect(store.getState().socket.byUser.u1.status).toBe("disconnected");
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBeNull();
|
||||
});
|
||||
|
||||
it("handles multiple users independently", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "connected" })
|
||||
);
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u2", status: "connecting" })
|
||||
);
|
||||
|
||||
expect(store.getState().socket.byUser.u1.status).toBe("connected");
|
||||
expect(store.getState().socket.byUser.u2.status).toBe("connecting");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import userReducer, { clearUser, setUser } from "../userSlice";
|
||||
import type { User } from "../../types/api";
|
||||
|
||||
function createStore() {
|
||||
return configureStore({ reducer: { user: userReducer } });
|
||||
}
|
||||
|
||||
const mockUser: User = {
|
||||
_id: "user-123",
|
||||
telegramId: 12345678,
|
||||
hasAccess: true,
|
||||
magicWord: "alpha",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
username: "testuser",
|
||||
role: "user",
|
||||
activeTeamId: "team-1",
|
||||
referral: {},
|
||||
subscription: { hasActiveSubscription: false, plan: "FREE" },
|
||||
settings: {
|
||||
dailySummariesEnabled: false,
|
||||
dailySummaryChatIds: [],
|
||||
autoCompleteEnabled: false,
|
||||
autoCompleteVisibility: "always",
|
||||
autoCompleteWhitelistChatIds: [],
|
||||
autoCompleteBlacklistChatIds: [],
|
||||
},
|
||||
usage: {
|
||||
cycleBudgetUsd: 10,
|
||||
spentThisCycleUsd: 0,
|
||||
spentTodayUsd: 0,
|
||||
cycleStartDate: new Date(),
|
||||
},
|
||||
autoDeleteTelegramMessagesAfterDays: 30,
|
||||
autoDeleteThreadsAfterDays: 30,
|
||||
};
|
||||
|
||||
describe("userSlice", () => {
|
||||
it("starts with null user", () => {
|
||||
const store = createStore();
|
||||
expect(store.getState().user.user).toBeNull();
|
||||
expect(store.getState().user.isLoading).toBe(false);
|
||||
expect(store.getState().user.error).toBeNull();
|
||||
});
|
||||
|
||||
it("sets user via setUser", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setUser(mockUser));
|
||||
expect(store.getState().user.user).toEqual(mockUser);
|
||||
expect(store.getState().user.error).toBeNull();
|
||||
});
|
||||
|
||||
it("clears user via clearUser", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setUser(mockUser));
|
||||
store.dispatch(clearUser());
|
||||
expect(store.getState().user.user).toBeNull();
|
||||
expect(store.getState().user.isLoading).toBe(false);
|
||||
expect(store.getState().user.error).toBeNull();
|
||||
});
|
||||
|
||||
it("sets user to null via setUser(null)", () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setUser(mockUser));
|
||||
store.dispatch(setUser(null));
|
||||
expect(store.getState().user.user).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* MSW request handlers for API mocking in tests.
|
||||
*
|
||||
* These provide deterministic API responses for testing components
|
||||
* that depend on backend data.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
const BACKEND_URL = "http://localhost:5005";
|
||||
|
||||
export const handlers = [
|
||||
// GET /telegram/me - Current user profile
|
||||
http.get(`${BACKEND_URL}/telegram/me`, () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
_id: "user-123",
|
||||
telegramId: 12345678,
|
||||
hasAccess: true,
|
||||
magicWord: "alpha",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
username: "testuser",
|
||||
role: "user" as const,
|
||||
activeTeamId: "team-1",
|
||||
referral: {},
|
||||
subscription: {
|
||||
hasActiveSubscription: false,
|
||||
plan: "FREE" as const,
|
||||
},
|
||||
settings: {
|
||||
dailySummariesEnabled: false,
|
||||
dailySummaryChatIds: [],
|
||||
autoCompleteEnabled: false,
|
||||
autoCompleteVisibility: "always" as const,
|
||||
autoCompleteWhitelistChatIds: [],
|
||||
autoCompleteBlacklistChatIds: [],
|
||||
},
|
||||
usage: {
|
||||
cycleBudgetUsd: 10,
|
||||
spentThisCycleUsd: 0,
|
||||
spentTodayUsd: 0,
|
||||
cycleStartDate: new Date().toISOString(),
|
||||
},
|
||||
autoDeleteTelegramMessagesAfterDays: 30,
|
||||
autoDeleteThreadsAfterDays: 30,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// GET /billing/current-plan
|
||||
http.get(`${BACKEND_URL}/billing/current-plan`, () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
plan: "FREE",
|
||||
hasActiveSubscription: false,
|
||||
planExpiry: null,
|
||||
subscription: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// GET /teams
|
||||
http.get(`${BACKEND_URL}/teams`, () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: [],
|
||||
});
|
||||
}),
|
||||
|
||||
// POST /auth/desktop-exchange
|
||||
http.post(`${BACKEND_URL}/auth/desktop-exchange`, () => {
|
||||
return HttpResponse.json({
|
||||
sessionToken: "mock-session-token",
|
||||
user: {
|
||||
id: "user-123",
|
||||
firstName: "Test",
|
||||
username: "testuser",
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* MSW server instance for use in Vitest tests.
|
||||
*/
|
||||
import { setupServer } from "msw/node";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Global test setup for Vitest.
|
||||
*
|
||||
* - Extends expect with @testing-library/jest-dom matchers
|
||||
* - Sets up MSW server for API mocking
|
||||
* - Silences console output during tests (unless DEBUG_TESTS=1)
|
||||
* - Mocks Tauri-specific modules that aren't available in test env
|
||||
* - Resets rate limiter module-level state between tests
|
||||
*/
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { server } from "./server";
|
||||
|
||||
// Mock import.meta.env defaults for tests
|
||||
vi.stubEnv("VITE_BACKEND_URL", "http://localhost:5005");
|
||||
vi.stubEnv("DEV", true);
|
||||
vi.stubEnv("MODE", "test");
|
||||
|
||||
// Mock Tauri APIs (not available in test env)
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-deep-link", () => ({
|
||||
onOpenUrl: vi.fn(),
|
||||
getCurrent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-os", () => ({
|
||||
platform: vi.fn().mockResolvedValue("macos"),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-shell", () => ({
|
||||
Command: vi.fn(),
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock tauriCommands to prevent Tauri API calls in tests
|
||||
vi.mock("../utils/tauriCommands", () => ({
|
||||
isTauri: () => false,
|
||||
storeSession: vi.fn().mockResolvedValue(undefined),
|
||||
getAuthState: vi.fn().mockResolvedValue({ is_authenticated: false }),
|
||||
exchangeToken: vi.fn(),
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the config module
|
||||
vi.mock("../utils/config", () => ({
|
||||
BACKEND_URL: "http://localhost:5005",
|
||||
TELEGRAM_BOT_USERNAME: "test_bot",
|
||||
TELEGRAM_BOT_ID: "12345",
|
||||
IS_DEV: true,
|
||||
SKILLS_GITHUB_REPO: "test/skills",
|
||||
DEV_AUTO_LOAD_SKILL: undefined,
|
||||
}));
|
||||
|
||||
// Mock redux-persist to avoid CJS/ESM issues in vitest
|
||||
vi.mock("redux-persist", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>(
|
||||
"redux-persist"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
// Override persistReducer to just return the base reducer
|
||||
persistReducer: (
|
||||
_config: unknown,
|
||||
reducer: (s: unknown, a: unknown) => unknown
|
||||
) => reducer,
|
||||
// Override persistStore to return a no-op persistor
|
||||
persistStore: () => ({
|
||||
subscribe: () => () => {},
|
||||
getState: () => ({}),
|
||||
dispatch: () => {},
|
||||
purge: () => Promise.resolve(),
|
||||
flush: () => Promise.resolve(),
|
||||
pause: () => {},
|
||||
persist: () => {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock redux-persist integration
|
||||
vi.mock("redux-persist/integration/react", () => ({
|
||||
PersistGate: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
loading?: unknown;
|
||||
persistor?: unknown;
|
||||
}) => children,
|
||||
}));
|
||||
|
||||
// Mock redux-logger to avoid noisy test output
|
||||
vi.mock("redux-logger", () => ({
|
||||
createLogger: () =>
|
||||
() =>
|
||||
(next: (action: unknown) => unknown) =>
|
||||
(action: unknown) =>
|
||||
next(action),
|
||||
}));
|
||||
|
||||
// Mock Sentry
|
||||
vi.mock("@sentry/react", () => ({
|
||||
init: vi.fn(),
|
||||
ErrorBoundary: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
fallback?: unknown;
|
||||
onError?: unknown;
|
||||
}) => children,
|
||||
withScope: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
setTag: vi.fn(),
|
||||
setUser: vi.fn(),
|
||||
}));
|
||||
|
||||
// Silence console during tests to keep output clean
|
||||
if (!process.env.DEBUG_TESTS) {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
}
|
||||
|
||||
// MSW server lifecycle
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" }));
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
cleanup();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
// Reset rate limiter per-request counter before each test.
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const { resetRequestCallCount } = await import("../lib/mcp/rateLimiter");
|
||||
if (typeof resetRequestCallCount === "function") {
|
||||
resetRequestCallCount();
|
||||
}
|
||||
} catch {
|
||||
// Module may be fully mocked in some test files — safe to skip
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Test utilities — provides a renderWithProviders helper that wraps
|
||||
* components in a fresh Redux store + MemoryRouter for isolated testing.
|
||||
*/
|
||||
import { render, type RenderOptions } from "@testing-library/react";
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import authReducer from "../store/authSlice";
|
||||
import socketReducer from "../store/socketSlice";
|
||||
import teamReducer from "../store/teamSlice";
|
||||
import userReducer from "../store/userSlice";
|
||||
|
||||
/**
|
||||
* Creates a fresh Redux store for testing.
|
||||
* Uses raw (non-persisted) reducers to avoid persist complexity in tests.
|
||||
*/
|
||||
export function createTestStore(preloadedState?: Record<string, unknown>) {
|
||||
return configureStore({
|
||||
// Cast reducer map to any to avoid strict typing issues in the test environment.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
reducer: ({ auth: authReducer, socket: socketReducer, user: userReducer, team: teamReducer } as unknown) as any,
|
||||
preloadedState: preloadedState as never,
|
||||
});
|
||||
}
|
||||
|
||||
type TestStore = ReturnType<typeof createTestStore>;
|
||||
|
||||
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
|
||||
preloadedState?: Record<string, unknown>;
|
||||
store?: TestStore;
|
||||
initialEntries?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a component wrapped in Redux Provider + MemoryRouter.
|
||||
*/
|
||||
export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
{
|
||||
preloadedState,
|
||||
store = createTestStore(preloadedState),
|
||||
initialEntries = ["/"],
|
||||
...renderOptions
|
||||
}: ExtendedRenderOptions = {}
|
||||
) {
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getArchitectureDisplayName,
|
||||
getDownloadLink,
|
||||
getPlatformDisplayName,
|
||||
parseReleaseAssetsByArchitecture,
|
||||
type GitHubReleaseAsset,
|
||||
} from "../deviceDetection";
|
||||
|
||||
describe("getArchitectureDisplayName", () => {
|
||||
it("returns correct names for known architectures", () => {
|
||||
expect(getArchitectureDisplayName("x64")).toBe("Intel (x64)");
|
||||
expect(getArchitectureDisplayName("aarch64")).toBe(
|
||||
"Apple Silicon (ARM64)"
|
||||
);
|
||||
expect(getArchitectureDisplayName("amd64")).toBe("AMD64");
|
||||
expect(getArchitectureDisplayName("unknown")).toBe("Unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlatformDisplayName", () => {
|
||||
it("returns human-readable platform names", () => {
|
||||
expect(getPlatformDisplayName("windows")).toBe("Windows");
|
||||
expect(getPlatformDisplayName("macos")).toBe("macOS");
|
||||
expect(getPlatformDisplayName("linux")).toBe("Linux");
|
||||
expect(getPlatformDisplayName("android")).toBe("Android");
|
||||
expect(getPlatformDisplayName("ios")).toBe("iOS");
|
||||
expect(getPlatformDisplayName("unknown")).toBe("Your Device");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDownloadLink", () => {
|
||||
it("returns release links when available", () => {
|
||||
const releaseLinks = {
|
||||
windows: "https://releases.example.com/app.exe",
|
||||
macos: "https://releases.example.com/app.dmg",
|
||||
};
|
||||
expect(getDownloadLink("windows", releaseLinks)).toBe(
|
||||
"https://releases.example.com/app.exe"
|
||||
);
|
||||
expect(getDownloadLink("macos", releaseLinks)).toBe(
|
||||
"https://releases.example.com/app.dmg"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to dummy links when no release links", () => {
|
||||
const link = getDownloadLink("linux");
|
||||
expect(link).toContain("example.com");
|
||||
expect(link).toContain("linux");
|
||||
});
|
||||
|
||||
it("falls back for unknown and ios platforms", () => {
|
||||
const releaseLinks = { windows: "https://example.com/w.exe" };
|
||||
const unknownLink = getDownloadLink("unknown", releaseLinks);
|
||||
expect(unknownLink).toBe("https://example.com/download");
|
||||
|
||||
const iosLink = getDownloadLink("ios", releaseLinks);
|
||||
expect(iosLink).toContain("apple.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReleaseAssetsByArchitecture", () => {
|
||||
const makeAsset = (
|
||||
name: string,
|
||||
url?: string
|
||||
): GitHubReleaseAsset => ({
|
||||
name,
|
||||
browser_download_url: url || `https://example.com/${name}`,
|
||||
content_type: "application/octet-stream",
|
||||
size: 1000,
|
||||
});
|
||||
|
||||
it("categorizes assets by platform", () => {
|
||||
const assets = [
|
||||
makeAsset("alphahuman-windows-x64-setup.exe"),
|
||||
makeAsset("alphahuman-macos-aarch64.dmg"),
|
||||
makeAsset("alphahuman-linux-x64.AppImage"),
|
||||
makeAsset("alphahuman-android-arm64.apk"),
|
||||
];
|
||||
|
||||
const links = parseReleaseAssetsByArchitecture(assets);
|
||||
expect(links.windows).toHaveLength(1);
|
||||
expect(links.macos).toHaveLength(1);
|
||||
expect(links.linux).toHaveLength(1);
|
||||
expect(links.android).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips .sig signature files", () => {
|
||||
const assets = [
|
||||
makeAsset("alphahuman-windows-x64-setup.exe"),
|
||||
makeAsset("alphahuman-windows-x64-setup.exe.sig"),
|
||||
];
|
||||
|
||||
const links = parseReleaseAssetsByArchitecture(assets);
|
||||
expect(links.windows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("groups multiple architectures per platform", () => {
|
||||
const assets = [
|
||||
makeAsset("alphahuman-macos-x64.dmg"),
|
||||
makeAsset("alphahuman-macos-aarch64.dmg"),
|
||||
];
|
||||
|
||||
const links = parseReleaseAssetsByArchitecture(assets);
|
||||
expect(links.macos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty when no matching assets", () => {
|
||||
const links = parseReleaseAssetsByArchitecture([]);
|
||||
expect(links.windows).toBeUndefined();
|
||||
expect(links.macos).toBeUndefined();
|
||||
expect(links.linux).toBeUndefined();
|
||||
expect(links.android).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers AppImage over deb/rpm for Linux", () => {
|
||||
const assets = [
|
||||
makeAsset("alphahuman-linux-x64.rpm"),
|
||||
makeAsset("alphahuman-linux-x64.AppImage"),
|
||||
];
|
||||
|
||||
const links = parseReleaseAssetsByArchitecture(assets);
|
||||
expect(links.linux).toHaveLength(1);
|
||||
expect(links.linux![0].fileName).toContain(".AppImage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSafeLogData,
|
||||
sanitizeError,
|
||||
sanitizeForLogging,
|
||||
} from "../sanitize";
|
||||
|
||||
describe("sanitizeError", () => {
|
||||
it("extracts name, message, and stack from Error objects", () => {
|
||||
const err = new Error("something broke");
|
||||
const result = sanitizeError(err) as Record<string, unknown>;
|
||||
expect(result.name).toBe("Error");
|
||||
expect(result.message).toBe("something broke");
|
||||
// DEV mode is set in test env, so stack should be present
|
||||
expect(result.stack).toBeDefined();
|
||||
});
|
||||
|
||||
it("sanitizes plain objects by redacting sensitive keys", () => {
|
||||
const input = { token: "abc123", username: "bob" };
|
||||
const result = sanitizeError(input) as Record<string, unknown>;
|
||||
expect(result.token).toBe("[REDACTED]");
|
||||
expect(result.username).toBe("bob");
|
||||
});
|
||||
|
||||
it("returns primitives as-is", () => {
|
||||
expect(sanitizeError("string error")).toBe("string error");
|
||||
expect(sanitizeError(42)).toBe(42);
|
||||
expect(sanitizeError(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeForLogging", () => {
|
||||
it("returns null/undefined as-is", () => {
|
||||
expect(sanitizeForLogging(null)).toBeNull();
|
||||
expect(sanitizeForLogging(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts sensitive keys in nested objects", () => {
|
||||
const input = {
|
||||
user: "alice",
|
||||
config: {
|
||||
apiKey: "secret-key",
|
||||
endpoint: "https://api.example.com",
|
||||
},
|
||||
};
|
||||
const result = sanitizeForLogging(input) as Record<string, unknown>;
|
||||
const config = result.config as Record<string, unknown>;
|
||||
expect(config.apiKey).toBe("[REDACTED]");
|
||||
expect(config.endpoint).toBe("https://api.example.com");
|
||||
});
|
||||
|
||||
it("truncates large objects and shows preview", () => {
|
||||
const bigData: Record<string, string> = {};
|
||||
for (let i = 0; i < 200; i++) {
|
||||
bigData[`field_${i}`] = `value_${"x".repeat(10)}`;
|
||||
}
|
||||
const result = sanitizeForLogging(bigData) as Record<string, unknown>;
|
||||
expect(result._truncated).toBe(true);
|
||||
expect(result._preview).toBeDefined();
|
||||
expect(typeof result._size).toBe("number");
|
||||
});
|
||||
|
||||
it("sanitizes arrays by iterating each element", () => {
|
||||
const input = [
|
||||
{ password: "hidden", name: "alice" },
|
||||
{ password: "hidden", name: "bob" },
|
||||
];
|
||||
const result = sanitizeForLogging(input) as Array<Record<string, unknown>>;
|
||||
expect(result[0].password).toBe("[REDACTED]");
|
||||
expect(result[0].name).toBe("alice");
|
||||
expect(result[1].password).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("stops at max recursion depth", () => {
|
||||
// Build deeply nested object
|
||||
let obj: Record<string, unknown> = { value: "deep" };
|
||||
for (let i = 0; i < 10; i++) {
|
||||
obj = { nested: obj };
|
||||
}
|
||||
const result = sanitizeForLogging(obj);
|
||||
// Should not throw, should have truncated
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSafeLogData", () => {
|
||||
it("includes metadata and marks hasData=false when no sensitiveData", () => {
|
||||
const result = createSafeLogData({ action: "login" });
|
||||
expect(result.action).toBe("login");
|
||||
expect(result.hasData).toBe(false);
|
||||
});
|
||||
|
||||
it("includes sanitized data for small payloads", () => {
|
||||
const result = createSafeLogData(
|
||||
{ action: "fetch" },
|
||||
{ userId: "123", token: "secret" }
|
||||
);
|
||||
expect(result.hasData).toBe(true);
|
||||
expect(result.dataSize).toBeGreaterThan(0);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.token).toBe("[REDACTED]");
|
||||
expect(data.userId).toBe("123");
|
||||
});
|
||||
|
||||
it("shows preview for large payloads", () => {
|
||||
const largePayload = "x".repeat(1000);
|
||||
const result = createSafeLogData({ action: "upload" }, largePayload);
|
||||
expect(result.hasData).toBe(true);
|
||||
expect(result.dataSize).toBe(1000);
|
||||
});
|
||||
});
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
// Global declarations for E2E tests (WebDriverIO globals)
|
||||
declare namespace NodeJS {
|
||||
interface Global {
|
||||
browser: any;
|
||||
}
|
||||
}
|
||||
|
||||
declare const browser: any;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Shared utilities for Appium mac2 + WebDriverIO E2E tests.
|
||||
*
|
||||
* The mac2 driver uses Apple's XCUITest to automate macOS apps.
|
||||
* It sees the WKWebView content through the accessibility tree.
|
||||
*
|
||||
* NOTE: The AlphaHuman app starts with visible:false (tray app).
|
||||
* The window is hidden by default — only the menu bar is visible.
|
||||
* Tests should account for this.
|
||||
*/
|
||||
|
||||
// `browser` is a global injected by WebDriverIO at runtime — do not redefine it.
|
||||
|
||||
/**
|
||||
* Wait for the app process to be ready.
|
||||
* The app starts with a hidden window, so we just wait for the process
|
||||
* to initialize (XCUITest has already launched it).
|
||||
*/
|
||||
export async function waitForApp() {
|
||||
await browser.pause(5_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any element matching the predicate exists.
|
||||
*/
|
||||
export async function elementExists(predicate) {
|
||||
try {
|
||||
const el = await browser.$(`-ios predicate string:${predicate}`);
|
||||
return await el.isExisting();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { elementExists, waitForApp } from "../helpers/app-helpers";
|
||||
|
||||
describe("Navigation", () => {
|
||||
before(async () => {
|
||||
await waitForApp();
|
||||
});
|
||||
|
||||
it("app has menu items in the menu bar", async () => {
|
||||
// A running macOS app always has menu bar items
|
||||
const hasMenuItems = await elementExists("elementType == 56");
|
||||
// elementType 56 = XCUIElementTypeMenuBarItem
|
||||
expect(hasMenuItems).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { waitForApp } from "../helpers/app-helpers";
|
||||
|
||||
describe("Smoke tests", () => {
|
||||
before(async () => {
|
||||
await waitForApp();
|
||||
});
|
||||
|
||||
it("app process launched successfully (session created)", async () => {
|
||||
// If we get here, Appium created a session for the app — it's running
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("app has a menu bar", async () => {
|
||||
const menuBar = await browser.$("//XCUIElementTypeMenuBar");
|
||||
expect(await menuBar.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it("app accessibility tree has elements", async () => {
|
||||
// Find any element in the app to confirm XCUITest can see it
|
||||
const elements = await browser.$$("//*");
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { waitForApp } from "../helpers/app-helpers";
|
||||
|
||||
describe("Tauri app integration", () => {
|
||||
before(async () => {
|
||||
await waitForApp();
|
||||
});
|
||||
|
||||
it("app has a menu bar (macOS native integration)", async () => {
|
||||
const menuBar = await browser.$("//XCUIElementTypeMenuBar");
|
||||
expect(await menuBar.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it("app can take a screenshot (XCUITest bridge works)", async () => {
|
||||
const screenshot = await browser.takeScreenshot();
|
||||
expect(screenshot).toBeTruthy();
|
||||
expect(screenshot.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["@wdio/globals/types", "@wdio/mocha-framework", "node"]
|
||||
},
|
||||
"include": ["test/e2e/**/*.ts", "wdio.conf.ts"]
|
||||
}
|
||||
+27
-9
@@ -1,3 +1,4 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
|
||||
@@ -18,26 +19,43 @@ export default defineConfig({
|
||||
process: "process/browser",
|
||||
util: "util",
|
||||
os: "os-browserify/browser",
|
||||
"@alphahuman/skill-types": path.resolve(
|
||||
__dirname,
|
||||
"src/lib/skills/types.ts"
|
||||
),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
environment: "jsdom",
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
setupFiles: ["src/__tests__/setup.ts"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
setupFiles: ["src/test/setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
hookTimeout: 30000,
|
||||
testTimeout: 30000,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: [
|
||||
"src/lib/mcp/**",
|
||||
"src/store/telegram/**",
|
||||
"src/services/updateManager.ts",
|
||||
"src/services/messageLoader.ts",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: [
|
||||
"src/main.tsx",
|
||||
"src/vite-env.d.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/test/**",
|
||||
"src/__tests__/**",
|
||||
"src/**/__tests__/**",
|
||||
"src/**/*.test.{ts,tsx}",
|
||||
"src/**/types.ts",
|
||||
"src/**/types/*.ts",
|
||||
"src/types/**",
|
||||
],
|
||||
reporter: ["text", "html"],
|
||||
reporter: ["text", "text-summary", "html", "lcov"],
|
||||
thresholds: {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 90,
|
||||
branches: 85,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import path from "path";
|
||||
import type { Options } from "@wdio/types";
|
||||
|
||||
/**
|
||||
* Resolve the path to the built Tauri application bundle.
|
||||
*
|
||||
* On macOS, Appium mac2 driver launches the .app via bundleId or app path.
|
||||
* On Windows/Linux, tauri-driver would be used instead (not covered here).
|
||||
*/
|
||||
function getAppPath(): string {
|
||||
const base = path.resolve("src-tauri/target/debug/bundle");
|
||||
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return path.join(base, "macos", "AlphaHuman.app");
|
||||
case "win32":
|
||||
return path.join(
|
||||
"src-tauri",
|
||||
"target",
|
||||
"debug",
|
||||
"AlphaHuman.exe",
|
||||
);
|
||||
case "linux":
|
||||
return path.join(
|
||||
"src-tauri",
|
||||
"target",
|
||||
"debug",
|
||||
"alpha-human",
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${process.platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const config: Options.Testrunner = {
|
||||
runner: "local",
|
||||
hostname: "127.0.0.1",
|
||||
port: 4723, // Appium default port
|
||||
specs: ["./test/e2e/specs/**/*.spec.ts"],
|
||||
maxInstances: 1, // Tauri apps are single-instance
|
||||
capabilities: [
|
||||
{
|
||||
platformName: "mac",
|
||||
// @ts-expect-error -- Appium capabilities are not in standard WebDriver types
|
||||
"appium:automationName": "Mac2",
|
||||
"appium:app": getAppPath(),
|
||||
"appium:bundleId": "com.alphahuman.app",
|
||||
"appium:showServerLogs": true,
|
||||
},
|
||||
],
|
||||
logLevel: "warn",
|
||||
bail: 0,
|
||||
waitforTimeout: 10_000,
|
||||
connectionRetryTimeout: 120_000,
|
||||
connectionRetryCount: 3,
|
||||
// No appium service — appium is started externally via scripts/start-appium.sh
|
||||
// so we can control the Node version (appium v3 requires Node >=24).
|
||||
framework: "mocha",
|
||||
reporters: ["spec"],
|
||||
mochaOpts: {
|
||||
ui: "bdd",
|
||||
timeout: 60_000, // App startup can be slow
|
||||
},
|
||||
autoCompileOpts: {
|
||||
tsNodeOpts: {
|
||||
project: "./tsconfig.e2e.json",
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user