Merge remote-tracking branch 'upstream/develop' into develop

This commit is contained in:
Steven Enamakel
2026-02-13 01:08:51 +05:30
32 changed files with 4793 additions and 53 deletions
+6
View File
@@ -40,3 +40,9 @@ src-tauri/runtime-skill-*
.cargo
CLAUDE.local.md
# Test artifacts
e2e-results/
wdio-logs/
test-results/
coverage/
+18
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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(),
+10
View File
@@ -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(),
+90 -3
View File
@@ -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, &params).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();
});
});
+64
View File
@@ -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);
});
});
+53
View File
@@ -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");
});
});
+78
View File
@@ -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");
});
});
+70
View File
@@ -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();
});
});
+83
View File
@@ -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",
},
});
}),
];
+7
View File
@@ -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);
+153
View File
@@ -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
}
});
+58
View File
@@ -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 }) };
}
+126
View File
@@ -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");
});
});
+111
View File
@@ -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);
});
});
+9
View File
@@ -0,0 +1,9 @@
// Global declarations for E2E tests (WebDriverIO globals)
declare namespace NodeJS {
interface Global {
browser: any;
}
}
declare const browser: any;
+35
View File
@@ -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;
}
}
+14
View File
@@ -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);
});
});
+23
View File
@@ -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);
});
});
+18
View File
@@ -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);
});
});
+14
View File
@@ -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
View File
@@ -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,
},
},
},
});
+69
View File
@@ -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",
},
},
};
+3157 -40
View File
File diff suppressed because it is too large Load Diff