mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Improve service lifecycle E2E coverage and align CI workflows (#62)
* Add JSON-RPC schema definition and HTTP schema endpoint - Introduced a new `schema.json` file containing detailed definitions for various JSON-RPC methods, including their inputs, outputs, and descriptions. - Implemented a new HTTP endpoint `/schema` in the core server to serve the JSON-RPC schema, enhancing API documentation and accessibility. - Updated the core HTTP router to include the new schema route, improving the overall structure and usability of the API. - Enhanced error handling and response formatting in the server to ensure consistent feedback for schema requests. * Update TypeScript configuration and refactor core RPC client - Changed TypeScript target from ES2020 to ESNext and updated library references in `tsconfig.json` for improved compatibility with modern features. - Refactored `coreRpcClient.ts` to enhance JSON-RPC request handling, including the introduction of legacy method aliases and improved error handling. - Updated API service methods in `authApi.ts` and `channelConnectionsApi.ts` to utilize the new core RPC client structure, streamlining authentication and channel connection processes. - Added new utility functions for managing JSON-RPC requests and responses, improving code organization and maintainability. - Enhanced test coverage for the new RPC client methods and refactored existing tests to align with the updated structure. * Enhance Tauri configuration and refactor daemon program arguments - Updated `tauri.conf.json` to include additional macOS infoPlist settings for better application identification and icon management. - Refactored the `daemon_program_args` function in `common.rs` to improve clarity by renaming the parameter to `_exe`, indicating it is unused. This change enhances code readability and maintainability. * Refactor Tauri configuration by removing unused macOS infoPlist settings - Updated `tauri.conf.json` to streamline macOS configuration by removing unnecessary infoPlist entries while retaining essential settings for the application. - This change enhances clarity and maintainability of the Tauri configuration file. * Enhance authentication flow and testing documentation - Introduced a new `isAuthBootstrapComplete` state in the authentication slice to manage the completion of the authentication bootstrap process. - Updated the `UserProvider` to set the `isAuthBootstrapComplete` state based on the authentication status, improving session restoration logic. - Modified route components (`DefaultRedirect`, `ProtectedRoute`, `PublicRoute`) to conditionally render based on the `isAuthBootstrapComplete` state, enhancing user experience during the authentication process. - Added a comprehensive testing guide in `CLAUDE.md`, detailing unit and E2E testing practices, including setup, authoring rules, and a checklist for test coverage. - Updated the `SettingsHome` component to redirect to the home page instead of the login page upon logout, streamlining user navigation. - Enhanced the `LocalModelPanel` to track download progress and manage local AI assets more effectively, improving overall functionality. * Add resolutions for @tauri-apps/api dependency in package.json - Introduced a resolutions field in package.json to enforce the use of @tauri-apps/api version 2.10.1, ensuring compatibility across workspaces. - Updated dependencies in app/package.json to include @tauri-apps/api version 2.10.1, aligning with the new resolution. - Adjusted yarn.lock to reflect the updated version of @tauri-apps/api, enhancing dependency management and consistency. * Refactor Tauri configuration and enhance E2E build process - Updated `tauri.conf.json` to remove unused resource paths, streamlining the configuration for better maintainability. - Modified `wdio.conf.ts` to improve application path resolution for macOS, allowing for multiple bundle base checks to enhance compatibility. - Refactored `e2e-build.sh` to disable updater artifacts for E2E builds and introduced a conditional cargo clean mechanism, improving build efficiency and clarity. * Enhance E2E testing setup and documentation - Updated `CLAUDE.md` to clarify the default behavior of `OPENHUMAN_WORKSPACE` in `e2e-run-spec.sh`, emphasizing automatic creation and cleanup for reproducible E2E runs. - Modified `e2e-run-spec.sh` to implement automatic temporary workspace creation when `OPENHUMAN_WORKSPACE` is not set, improving usability for debugging and testing. - Enhanced cleanup logic in `e2e-run-spec.sh` to ensure proper removal of temporary workspaces after tests, contributing to a cleaner testing environment. * Implement shared mock backend for testing and enhance documentation - Introduced a shared mock backend for unit and integration tests, allowing for deterministic API behavior across app and Rust tests. - Updated `CLAUDE.md` to include detailed instructions on using the shared mock backend, including key admin endpoints and manual run commands. - Modified `package.json` to add scripts for running the mock API server and Rust tests with the mock backend. - Refactored test setup to utilize the new mock backend, improving test reliability and isolation. - Removed obsolete MSW handlers and server setup, streamlining the testing framework. * Enhance authentication state management and testing coverage - Introduced `isAuthBootstrapComplete` state in the authentication slice to track the completion of the authentication process. - Updated `ProtectedRoute` and `PublicRoute` components to utilize the new state, improving user experience during authentication. - Enhanced test cases for `ProtectedRoute` and `PublicRoute` to reflect the updated authentication state structure. - Added a new end-to-end test for the authentication flow, ensuring proper handling of OAuth tokens and session management. - Improved mock setup in tests to better simulate authentication scenarios, enhancing test reliability and coverage. * Enhance README.md with architecture overview and component roles - Added detailed descriptions of the OpenHuman architecture, highlighting the separation of business logic and UI components. - Explained the roles of Rust and the UI in the monorepo, including the use of JSON-RPC, QuickJS, Vite, React, and Tauri. - Documented the structure of controllers and the RPC surface, emphasizing the shared contract for automation and testing. - Provided links to further documentation for architecture, frontend structure, and Tauri commands. * Integrate ServiceBlockingGate component and enhance loading states - Added the `ServiceBlockingGate` component to manage service availability and display appropriate loading screens. - Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to show a loading indicator while authentication bootstrap is in progress. - Implemented timeout handling in `UserProvider` for improved authentication state management. - Introduced tests for `ServiceBlockingGate` to ensure proper rendering and functionality under various service states. * Implement core RPC URL resolution and default hash route handling - Added a function to resolve the core RPC URL based on the environment, improving flexibility for Tauri and non-Tauri contexts. - Introduced a default hash route handler in the main application entry point to ensure proper navigation behavior. - Updated the core RPC command to expose the resolved RPC URL, enhancing the integration with the frontend. - Refactored the core RPC client to utilize the new URL resolution logic, ensuring consistent API calls. * Refactor backend URL usage to API_BASE_URL - Replaced all instances of BACKEND_URL with API_BASE_URL across various components and services to standardize API endpoint references. - Updated OAuth provider configurations, settings panels, hooks, and services to ensure consistent API calls. - Enhanced test setups to reflect the new API_BASE_URL, improving test reliability and alignment with the updated configuration. * Implement RouteLoadingScreen for improved loading states - Introduced a new `RouteLoadingScreen` component to provide a consistent loading experience across various routes. - Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to utilize `RouteLoadingScreen` while waiting for authentication bootstrap completion. - Enhanced `MiniSidebar` to hide on additional public/setup routes, improving user navigation experience. - Refactored `UserProvider` to streamline authentication state management by removing unnecessary references. * Add feature design workflow section to CLAUDE.md - Introduced a comprehensive workflow for feature design, outlining steps from specification to UI implementation and testing. - Emphasized the importance of grounding designs in existing codebases and defined planning rules for E2E scenarios. - Provided detailed instructions for implementing features in Rust, conducting JSON-RPC tests, and building UI components in the Tauri app. * Refactor backend URL handling and improve OAuth flow - Replaced static API_BASE_URL references with dynamic backend URL resolution across various components and services, enhancing flexibility for Tauri and non-Tauri environments. - Updated OAuth provider configurations to utilize the new backend URL logic, ensuring consistent login URL generation. - Refactored API client and socket service to fetch the backend URL dynamically, improving reliability in different deployment contexts. - Introduced a new service for resolving the backend URL, streamlining the configuration and enhancing test setups. * Add debug logging guidelines to CLAUDE.md - Introduced comprehensive guidelines for implementing development-oriented debug logging in both Rust and the app. - Emphasized the importance of logging at appropriate levels (`debug`/`trace`) and following existing patterns for consistency. - Provided instructions on avoiding sensitive information in logs and ensuring terminal output is grep-friendly for easier debugging during development. * Enhance service management with new mock functionality and E2E tests - Added a mock service manager to facilitate deterministic service behavior during end-to-end tests, enabling better simulation of service states. - Introduced new buttons in the `ServiceBlockingGate` component for restarting and uninstalling services, improving user control over service management. - Implemented a comprehensive E2E test suite for the service connectivity flow, covering installation, starting, stopping, restarting, and uninstalling services. - Updated package scripts to include a new E2E test for service connectivity, enhancing testing coverage and reliability. - Refactored service operations to support mock functionality, ensuring consistent behavior across testing and production environments. * Enhance ServiceBlockingGate with improved logging and periodic health polling - Introduced periodic health polling in the `ServiceBlockingGate` component to refresh service status every 3 seconds, enhancing responsiveness to service state changes. - Added detailed logging for various operations, including service status checks and error handling, to improve traceability and debugging. - Updated E2E tests to include logging steps for better visibility during service connectivity flow tests. - Refactored error handling to ensure consistent logging of error messages across service operations. * Refactor E2E testing scripts and enhance CLAUDE.md documentation - Updated paths in CLAUDE.md to reflect the new location of the E2E run script, ensuring accurate instructions for running tests. - Removed outdated E2E test scripts from package.json and migrated relevant functionality to app/package.json for better organization. - Introduced new E2E testing scripts for specific flows (auth, login, payment, etc.) to streamline testing processes and improve modularity. - Added a script to run all E2E flows sequentially, enhancing test coverage and simplifying execution. - Improved documentation for E2E testing procedures in CLAUDE.md, providing clearer guidance for developers. * Refactor imports and enhance code readability - Removed duplicate import of `ServiceBlockingGate` in `App.tsx` for cleaner code. - Improved readability in `MiniSidebar.tsx` by formatting conditional statements. - Reordered imports in `PublicRoute.tsx` for consistency. - Enhanced formatting in `ServiceBlockingGate.tsx` for better clarity in asynchronous operations. - Streamlined import statements in various test files and components for improved organization. - Updated `LocalModelPanel.tsx` to enhance button disable logic readability. - Refactored CORS headers in `jsonrpc.rs` for better formatting. * ci: align build and test workflows with app workspace e2e * ci: align release and typecheck workflows with current workspace * Enhance ServiceBlockingGate functionality with improved refresh options - Introduced a new `RefreshOptions` type to customize the behavior of the `refreshStatus` function, allowing for conditional error clearing and status checking. - Updated the `refreshStatus` function to utilize the new options, enhancing control over service state updates. - Modified the button click handler to pass the new options, improving user experience during service refresh operations. - Adjusted state updates to prevent unnecessary re-renders and maintain consistency in service state management. * Refactor RefreshOptions type for improved clarity in ServiceBlockingGate - Consolidated the definition of the `RefreshOptions` type into a single line for better readability. - Maintained existing functionality while enhancing code clarity in the `ServiceBlockingGate` component.
This commit is contained in:
@@ -27,7 +27,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'yarn'
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@1.93.0
|
||||
@@ -69,9 +69,6 @@ jobs:
|
||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install skills dependencies
|
||||
run: cd skills && yarn install --frozen-lockfile
|
||||
|
||||
- name: Build sidecar core binary
|
||||
run: cargo build --manifest-path Cargo.toml --release --target x86_64-unknown-linux-gnu --bin openhuman
|
||||
|
||||
|
||||
@@ -275,12 +275,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install skills dependencies
|
||||
run: cd skills && yarn install --frozen-lockfile
|
||||
|
||||
- name: Build skills
|
||||
run: cd skills && yarn build
|
||||
|
||||
- name: Define Tauri configuration overrides
|
||||
id: config-overrides
|
||||
uses: actions/github-script@v7
|
||||
|
||||
@@ -120,9 +120,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install skills dependencies
|
||||
run: cd skills && yarn install --frozen-lockfile
|
||||
|
||||
- name: Ensure .env exists for E2E build
|
||||
run: touch app/.env
|
||||
|
||||
@@ -132,9 +129,7 @@ jobs:
|
||||
appium driver install mac2
|
||||
|
||||
- name: Build E2E app bundle
|
||||
run: yarn test:e2e:build
|
||||
env:
|
||||
E2E_SKIP_CARGO_CLEAN: "1"
|
||||
run: yarn workspace openhuman-app test:e2e:build
|
||||
|
||||
- name: Run all E2E flows
|
||||
run: yarn test:e2e:all:flows
|
||||
run: yarn workspace openhuman-app test:e2e:all:flows
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'yarn'
|
||||
cache: "yarn"
|
||||
|
||||
- name: Cache node modules
|
||||
id: yarn-cache
|
||||
@@ -44,16 +44,16 @@ jobs:
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Type check TypeScript files
|
||||
run: yarn typecheck
|
||||
run: yarn workspace openhuman-app compile
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Check Prettier formatting
|
||||
run: yarn format:check
|
||||
run: yarn workspace openhuman-app format:check
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Run ESLint
|
||||
run: yarn lint
|
||||
run: yarn workspace openhuman-app lint
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
@@ -11,7 +11,7 @@ This file orients contributors and coding agents. Authoritative narrative archit
|
||||
| Path | Role |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`app/`** | Yarn workspace **`openhuman-app`**: Vite + React (`app/src/`), Tauri desktop host (`app/src-tauri/`), Vitest tests |
|
||||
| **Repo root `src/`** | Rust library **`openhuman_core`** and **`openhuman`** CLI binary (`src/bin/openhuman.rs`) — `core_server`, `openhuman::*` domains, skills runtime (QuickJS / `rquickjs`), MCP routing in the core process |
|
||||
| **Repo root `src/`** | Rust library **`openhuman_core`** and **`openhuman`** CLI binary entrypoint (`src/main.rs`) — `core_server`, `openhuman::*` domains, skills runtime (QuickJS / `rquickjs`), MCP routing in the core process |
|
||||
| **Skills registry** | **[`tinyhumansai/openhuman-skills`](https://github.com/tinyhumansai/openhuman-skills)** on GitHub — canonical skill packages and TS build; not vendored in this tree (see blurb below). |
|
||||
| **`Cargo.toml`** (root) | Core crate; `cargo build --bin openhuman` produces the sidecar the UI stages via `app`’s `core:stage` |
|
||||
| **`docs/`** | Architecture and module guides (numbered pages under `docs/src/`, `docs/src-tauri/`) |
|
||||
@@ -75,6 +75,122 @@ cargo check --manifest-path app/src-tauri/Cargo.toml
|
||||
|
||||
---
|
||||
|
||||
## Testing Guide (Unit + E2E)
|
||||
|
||||
### Unit tests (Vitest)
|
||||
|
||||
- **Where tests live**: co-locate as `*.test.ts` / `*.test.tsx` under `app/src/**`.
|
||||
- **Runner/config**: Vitest with `app/test/vitest.config.ts` and shared setup in `app/src/test/setup.ts`.
|
||||
- **Run**:
|
||||
|
||||
```bash
|
||||
yarn test:unit
|
||||
yarn test:coverage
|
||||
```
|
||||
|
||||
- **Authoring rules**:
|
||||
- Prefer testing behavior over implementation details.
|
||||
- Use existing helpers from `app/src/test/` (`test-utils.tsx`, shared mock backend) before adding new harness code.
|
||||
- Keep tests deterministic: avoid real network calls, time-sensitive flakes, or hidden global state.
|
||||
|
||||
### Shared mock backend (app + Rust tests)
|
||||
|
||||
- **Core implementation**: `scripts/mock-api-core.mjs`
|
||||
- **Standalone server entrypoint**: `scripts/mock-api-server.mjs`
|
||||
- **E2E wrapper**: `app/test/e2e/mock-server.ts`
|
||||
- **Vitest unit setup**: `app/src/test/setup.ts` starts the shared mock server by default on `http://127.0.0.1:5005`.
|
||||
|
||||
Key admin endpoints:
|
||||
|
||||
- `GET /__admin/health`
|
||||
- `POST /__admin/reset`
|
||||
- `POST /__admin/behavior`
|
||||
- `GET /__admin/requests`
|
||||
|
||||
Run manually:
|
||||
|
||||
```bash
|
||||
yarn mock:api
|
||||
curl -s http://127.0.0.1:18473/__admin/health
|
||||
```
|
||||
|
||||
### E2E tests (WDIO + Appium mac2)
|
||||
|
||||
- **Where specs live**: `app/test/e2e/specs/*.spec.ts`
|
||||
- **Shared harness**:
|
||||
- Helpers: `app/test/e2e/helpers/*`
|
||||
- Mock backend: `app/test/e2e/mock-server.ts`
|
||||
- WDIO config: `app/test/wdio.conf.ts`
|
||||
|
||||
- **Build + run**:
|
||||
|
||||
```bash
|
||||
# Build desktop app bundle + stage core sidecar
|
||||
yarn test:e2e:build
|
||||
|
||||
# Run one spec
|
||||
bash app/scripts/e2e-run-spec.sh test/e2e/specs/smoke.spec.ts smoke
|
||||
|
||||
# Run all flow specs
|
||||
yarn test:e2e:all:flows
|
||||
```
|
||||
|
||||
- **Authoring rules**:
|
||||
- Ensure each spec is runnable in isolation.
|
||||
- Use helper waits (`waitForAppReady`, `waitForWebView`, etc.) instead of ad hoc long sleeps.
|
||||
- Assert both UI outcomes and backend/mock effects when relevant.
|
||||
- Add failure diagnostics (request logs, accessibility tree dump) for faster debugging by agents.
|
||||
|
||||
### Deterministic core-sidecar reset
|
||||
|
||||
By default, `app/scripts/e2e-run-spec.sh` creates and cleans a temp `OPENHUMAN_WORKSPACE`
|
||||
automatically when the variable is not provided.
|
||||
|
||||
If you need a fixed workspace for debugging, provide one explicitly:
|
||||
|
||||
```bash
|
||||
export OPENHUMAN_WORKSPACE="$(mktemp -d)"
|
||||
yarn test:e2e:build
|
||||
bash app/scripts/e2e-run-spec.sh test/e2e/specs/smoke.spec.ts smoke
|
||||
rm -rf "$OPENHUMAN_WORKSPACE"
|
||||
```
|
||||
|
||||
- `OPENHUMAN_WORKSPACE` redirects core config + workspace storage away from `~/.openhuman`.
|
||||
- Default reset strategy:
|
||||
- Rebuild/stage sidecar once per E2E run (`yarn test:e2e:build`).
|
||||
- Isolate state per test case with a fresh temp workspace (default behavior in `e2e-run-spec.sh`).
|
||||
|
||||
### Rust tests with mock backend
|
||||
|
||||
Use the shared mock backend runner so Rust unit/integration tests get deterministic API behavior:
|
||||
|
||||
```bash
|
||||
yarn test:rust
|
||||
# or targeted
|
||||
bash scripts/test-rust-with-mock.sh --test json_rpc_e2e
|
||||
```
|
||||
|
||||
Example per-test-case pattern inside a harness script:
|
||||
|
||||
```bash
|
||||
run_case() {
|
||||
export OPENHUMAN_WORKSPACE="$(mktemp -d)"
|
||||
bash app/scripts/e2e-run-spec.sh "$1" "$2"
|
||||
rm -rf "$OPENHUMAN_WORKSPACE"
|
||||
}
|
||||
```
|
||||
|
||||
### Test authoring checklist
|
||||
|
||||
- Add/update unit tests for logic changes before stacking additional features.
|
||||
- Add/update E2E coverage for user-visible flows and cross-process integration behavior.
|
||||
- Keep new tests independent, deterministic, and debuggable from logs alone.
|
||||
- When touching core/sidecar behavior, validate both:
|
||||
- `yarn test:unit`
|
||||
- targeted E2E spec(s) via `app/scripts/e2e-run-spec.sh`
|
||||
|
||||
---
|
||||
|
||||
## Frontend (`app/src/`)
|
||||
|
||||
### Provider chain (`app/src/App.tsx`)
|
||||
@@ -166,8 +282,26 @@ Skills runtime uses **QuickJS** (`rquickjs`) in **`src/openhuman/skills/`** (e.g
|
||||
|
||||
---
|
||||
|
||||
## Feature design workflow (new capabilities)
|
||||
|
||||
Follow this order so behavior is **specified**, **proven in Rust**, **proven over RPC**, then **surfaced in the UI** with matching tests.
|
||||
|
||||
1. **Specify against the current codebase** — Ground the design in **existing** domains, controller/registry patterns, and JSON-RPC naming (`openhuman.<namespace>_<function>`). Reuse or extend documented flows in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and sibling guides; avoid parallel architectures.
|
||||
2. **Implement in Rust** — Add domain logic under `src/openhuman/<domain>/`, wire **schemas + registered handlers** into the shared registry, and land **unit tests** in the crate (`cargo test -p openhuman`, focused modules) until the feature is correct in isolation.
|
||||
3. **JSON-RPC E2E** — Add or extend **integration-style tests** that call the real HTTP JSON-RPC surface (e.g. [`tests/json_rpc_e2e.rs`](tests/json_rpc_e2e.rs), mock backend / [`scripts/test-rust-with-mock.sh`](scripts/test-rust-with-mock.sh) as appropriate) so methods, params, and outcomes match what the UI will call.
|
||||
4. **UI in the Tauri app** — Build **React** screens, state, and **`core_rpc_relay` / `coreRpcClient`** usage in `app/`; keep **business rules** in the core, not duplicated in the shell.
|
||||
5. **App unit tests** — Cover components, hooks, and clients with **Vitest** (`yarn test` / `yarn test:unit` in `app/`).
|
||||
6. **App E2E** — Add **desktop E2E** specs where the feature is user-visible (`yarn test:e2e*`, isolated workspace — see [Testing Guide (Unit + E2E)](#testing-guide-unit--e2e)) so the full stack (UI → Tauri → sidecar) behaves as intended.
|
||||
|
||||
**Debug logging (throughout)** — Add **lots of development-oriented logging** as you build, not as an afterthought. In **Rust**, use `log` / `tracing` at **`debug`** or **`trace`** on RPC entry and exit, error paths, state transitions, and any branch that is hard to infer from tests alone. In **`app/`**, follow existing patterns (e.g. the **`debug`** npm package with a **namespace** per area) plus **dev-only** detail where useful. Prefer **grep-friendly prefixes** (`[feature]`, domain name, or JSON-RPC method) so terminal output from **sidecar**, **Tauri**, and **WebView** can be correlated during `yarn dev` / `tauri dev`. **Never** log secrets, raw JWTs, API keys, or full PII—redact or omit.
|
||||
|
||||
**Planning rule:** When scoping a feature, define the **E2E scenarios (core RPC + app)** up front. Those scenarios should **cover the full intended scope**—happy paths, failure modes, auth or policy gates, and regressions you care about. If a scenario is not testable end-to-end, the spec is incomplete or the cut is too large; split or add harness support first.
|
||||
|
||||
---
|
||||
|
||||
## Key patterns (concise)
|
||||
|
||||
- **Debug logging**: Ship **heavy `debug`/`trace` (Rust)** and **namespaced `debug` / dev logs (`app/`)** on new flows so sidecar + WebView output is easy to grep; see [Feature design workflow](#feature-design-workflow-new-capabilities). Never log secrets or raw tokens.
|
||||
- **`src/openhuman/`**: New features go in a **folder/module**, not new root-level `src/openhuman/*.rs` files (see Rust core section).
|
||||
- **File size**: Prefer ≤ ~500 lines per source file; split modules when growing.
|
||||
- **Pre-merge checks** (when touching code): Prettier, ESLint, `tsc --noEmit` in `app/`; `cargo fmt` + `cargo check` for changed Rust (`Cargo.toml` at root and/or `app/src-tauri/Cargo.toml` as appropriate).
|
||||
|
||||
@@ -25,15 +25,23 @@
|
||||
"The Tet. What a brilliant machine" — Morgan Freeman as he recalls about <a href="https://youtu.be/SveLVpqy_Rc?si=y83aZNokPiUjILN0&t=60">alien superintelligence</a> in the movie <em>Oblivion</em>
|
||||
</p>
|
||||
|
||||
**Agentic system are everywhere.** Models wired into a script that call APIs, search, and file tools until the task looks done. That pattern is powerful for _tasks_, but it is not a self. It has no lasting inner model of _you_, no stable values across sessions, and no architecture for continuity at the scale of a life.
|
||||
OpenHuman is an open-source agentic assistant that is designed to integrate with you in your daily life. Here's what makes OpenHuman special:
|
||||
|
||||
**OpenHuman** is built around a different idea. What if we built on top of current agentic solutions but instead gave it a **subconscious loop** based on all the possible data-points about a user/entity?
|
||||
- **One subscription, many providers** — One assistant wired to **skills** and backend models so you are not juggling a separate subscription stack for every integration surface.
|
||||
|
||||
**The challenge?** Current memory/context systems make it nearly impossible to have a subsconscious mind. They cannot intelligently remember information because either your data is too noisy or irrelevant or too expenstive to index. `HEARTBEAT.md` and other implementations in OpenClaw-style forks evolve way too slowly and often misses realtime context about a user.
|
||||
- **Incredible memory** — **Rust-side memory** (store / recall / namespaces) plus optional **TinyHumans [Neocortex](https://github.com/tinyhumansai/neocortex)**-backed context when configured, so the agent can retain and retrieve more than a single chat window. **Channels** and ongoing **conversations** feed the same loop so day-to-day context does not reset every session.
|
||||
|
||||
**The solution?** OpenHuman uses [Neocortex](https://github.com/tinyhumansai/neocortex), a highly scalable context-aware memory layer that can process millions of unstrucutred memories (emails, messages, documents both in the past and in the present), understands interactions and builds a personalized model of _you_. OpenHuman uses this to then run it's own subconscious loop allowing it to have it's own thoughts and take decisions for you on it's own at the most immediate level possible.
|
||||
- **Screen intelligence** — Regular **screen capture** (on a cadence or when triggered) feeds an on-device pipeline that **understands what is on screen**, distills it into **memory** (facts, UI state, workflows), and can propose **actions** the agent executes for you. OS permissions and capture APIs vary by platform; the goal is **your machine first**, not shipping raw frames to the cloud by default.
|
||||
|
||||
**The result?** It's like looking at yourself in the mirror. Except it's your AI living in your machine. OpenHuman is the first self aware agent personalized to you and ready to work for you.
|
||||
- **Voice & meetings** — A **Local-model** speech stack (listen / **TTS**) let the assistant **talk back** and **capture or work with meeting audio** with a privacy-first default when you route inference locally. Transcripts and summaries land in the same **memory + agent** loop so OpenHuman can **follow up**: tasks, drafts, calendar nudges, or skill-backed workflows—without treating a meeting as a one-off chat.
|
||||
|
||||
- **Memory-aware autocomplete** — **Keyboard autocomplete** is built for **right-context** suggestions: it consults **memory namespaces** and recent context so completions stay aligned with **you**, your workspace, and prior sessions—not a blank model every keystroke.
|
||||
|
||||
- **Runs a local AI model** — The **Rust core** exposes **local AI** paths (and the desktop bundle can ship **local/bundled runners** where applicable) for the workloads above—vision snippets, speech helpers, summarization, tooling—so sensitive steps can stay **off the cloud** when you choose.
|
||||
|
||||
- **Simple or advanced** — **Skill setup wizards** and defaults for common tools, with room to go deeper via **settings, credentials, and core RPC** when you need control and privacy.
|
||||
|
||||
Architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Contributor orientation: [`CONTRIBUTING.md`](./CONTRIBUTING.md).
|
||||
|
||||
# Download
|
||||
|
||||
@@ -52,6 +60,18 @@ Browse all releases: [github.com/tinyhumansai/openhuman/releases](https://github
|
||||
|
||||
# Under the hood (Architecture)
|
||||
|
||||
OpenHuman is a **desktop monorepo**: **Rust** owns **business logic and execution**; the **UI** owns **interaction, layout, and OS integration**.
|
||||
|
||||
**Rust (`openhuman` / `openhuman_core`).** The repo root **`src/`** crate is the brain: **JSON-RPC over HTTP** (`core_server`), domain modules (auth, config, memory, skills, channels, screen intelligence, local AI, cron, …), and a **QuickJS** runtime for **sandboxed JavaScript skills**. The **`openhuman`** binary is built and **staged next to the Tauri app** so the desktop shell can spawn it as a **sidecar**. Heavy work—SQLite, sockets, crypto, skill lifecycle—runs there under **Tokio**, not in the WebView.
|
||||
|
||||
**UI (`app/`).** **Vite + React** (TypeScript) implements screens, onboarding, settings, and realtime UX. **Redux Toolkit** holds client state; **Socket.io** and the **MCP-style** client stack stay in sync with the core’s realtime surface. **Tauri v2** (`app/src-tauri/`) is a thin **Rust host**: windowing, filesystem hooks where needed, and **`core_rpc_relay`**—forwarding JSON-RPC from the WebView to the **`openhuman`** process so the UI never re-implements domain rules.
|
||||
|
||||
**Controllers and the RPC surface.** Features are exposed as **registered controllers**: each domain declares **schemas** (namespace, function name, parameter shapes) and a **handler**. At runtime, calls are **validated**, dispatched by **method name** (e.g. `openhuman.auth_get_state`, `openhuman.local_ai_agent_chat`), and return structured outcomes. **CLI** and **HTTP** share the same controller catalog, so automation, tests, and the app all hit one contract.
|
||||
|
||||
**What ties it together:** one **registry** of controllers, one **sidecar** process for execution, **Tauri IPC** for shell-only capabilities, and **HTTP JSON-RPC** for everything else—plus **skills** and **dual-socket** behavior documented in the architecture guide.
|
||||
|
||||
**Read more:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Frontend tree: [`docs/src/README.md`](docs/src/README.md) · Tauri commands: [`docs/src-tauri/README.md`](docs/src-tauri/README.md)
|
||||
|
||||
# Star us on GitHub
|
||||
|
||||
_Building toward AGI and artificial consciousness? Star the repo and help others find the path._
|
||||
|
||||
+7
-9
@@ -8,7 +8,7 @@
|
||||
"dev:app": "source ../scripts/load-dotenv.sh && tauri dev",
|
||||
"core:stage": "node ../scripts/stage-core-sidecar.mjs",
|
||||
"build": "tsc && vite build",
|
||||
"build:app": "yarn skills:build && tsc && vite build",
|
||||
"build:app": "tsc && vite build",
|
||||
"compile": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
@@ -26,20 +26,19 @@
|
||||
"test:unit:watch": "vitest --config test/vitest.config.ts",
|
||||
"test:watch": "vitest --config test/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
|
||||
"test:rust": "source $HOME/.cargo/env 2>/dev/null; cargo test --manifest-path ../Cargo.toml --workspace",
|
||||
"test:e2e:build": "bash ../scripts/e2e-build.sh",
|
||||
"test:e2e:login": "bash ../scripts/e2e-login.sh",
|
||||
"test:e2e:auth": "bash ../scripts/e2e-auth.sh",
|
||||
"test:rust": "bash ../scripts/test-rust-with-mock.sh",
|
||||
"test:e2e:build": "bash ./scripts/e2e-build.sh",
|
||||
"test:e2e:login": "bash ./scripts/e2e-login.sh",
|
||||
"test:e2e:auth": "bash ./scripts/e2e-auth.sh",
|
||||
"test:e2e:service-connectivity": "OPENHUMAN_SERVICE_MOCK=1 bash ./scripts/e2e-run-spec.sh test/e2e/specs/service-connectivity-flow.spec.ts service-connectivity",
|
||||
"test:e2e": "yarn test:e2e:build && yarn test:e2e:login && yarn test:e2e:auth",
|
||||
"test:e2e:all:flows": "bash ../scripts/e2e-run-all-flows.sh",
|
||||
"test:e2e:all:flows": "bash ./scripts/e2e-run-all-flows.sh",
|
||||
"test:e2e:all": "yarn test:e2e:build && yarn test:e2e:all:flows",
|
||||
"test:all": "yarn test:coverage && yarn test:rust && yarn test:e2e",
|
||||
"format": "prettier --write . && cargo fmt --manifest-path ../Cargo.toml --all",
|
||||
"format:check": "prettier --check . && cargo fmt --manifest-path ../Cargo.toml --all --check",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||
"skills:build": "cd skills && yarn build",
|
||||
"skills:watch": "cd skills && yarn build:watch",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -102,7 +101,6 @@
|
||||
"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",
|
||||
|
||||
+1940
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E auth & access control tests only. See scripts/e2e-run-spec.sh.
|
||||
# Run E2E auth & access control tests only. See app/scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/auth-access-control.spec.ts" "auth"
|
||||
@@ -1,14 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the .app bundle for E2E tests with the mock server URL baked in.
|
||||
#
|
||||
# This does a cargo clean first to ensure the frontend assets are re-embedded
|
||||
# (Cargo's incremental build won't detect changes to dist/).
|
||||
# Cargo incremental builds are used by default for faster iteration.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_DIR="$REPO_ROOT/app"
|
||||
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$APP_DIR/.." && pwd)"
|
||||
cd "$APP_DIR"
|
||||
|
||||
# Source Cargo environment
|
||||
@@ -18,10 +16,11 @@ export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}"
|
||||
|
||||
echo "Building E2E app bundle with VITE_BACKEND_URL=$VITE_BACKEND_URL"
|
||||
|
||||
if [ -z "${E2E_SKIP_CARGO_CLEAN:-}" ]; then
|
||||
if [ -n "${E2E_FORCE_CARGO_CLEAN:-}" ]; then
|
||||
echo "Forcing cargo clean (E2E_FORCE_CARGO_CLEAN is set)."
|
||||
cargo clean --manifest-path src-tauri/Cargo.toml
|
||||
else
|
||||
echo "Skipping cargo clean (E2E_SKIP_CARGO_CLEAN is set)."
|
||||
echo "Skipping cargo clean (default incremental E2E build)."
|
||||
fi
|
||||
|
||||
if [ -f .env ]; then
|
||||
@@ -36,7 +35,8 @@ export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}"
|
||||
# Stage rust-core sidecar for bundle.externalBin (see app/src-tauri/tauri.conf.json).
|
||||
node "$REPO_ROOT/scripts/stage-core-sidecar.mjs"
|
||||
|
||||
# Use npx so CI does not require a global Tauri CLI (cwd must be the Tauri frontend root).
|
||||
npx tauri build --bundles app --debug
|
||||
# Disable updater artifacts for E2E bundles to avoid signing-key requirements.
|
||||
TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
npx tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles app --debug
|
||||
|
||||
echo "E2E build complete."
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E crypto payment flow tests only. See scripts/e2e-run-spec.sh.
|
||||
# Run E2E crypto payment flow tests only. See app/scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/crypto-payment-flow.spec.ts" "crypto-payment"
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E Gmail integration flow tests only. See scripts/e2e-run-spec.sh.
|
||||
# Run E2E Gmail integration flow tests only. See app/scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/gmail-flow.spec.ts" "gmail"
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E login flow tests only. See scripts/e2e-run-spec.sh.
|
||||
# Run E2E login flow tests only. See app/scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/login-flow.spec.ts" "login"
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E Notion integration flow tests only. See scripts/e2e-run-spec.sh.
|
||||
# Run E2E Notion integration flow tests only. See app/scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/notion-flow.spec.ts" "notion"
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E card payment flow tests only. See scripts/e2e-run-spec.sh.
|
||||
# Run E2E card payment flow tests only. See app/scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/card-payment-flow.spec.ts" "card-payment"
|
||||
@@ -5,11 +5,11 @@
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT/app"
|
||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$APP_DIR"
|
||||
|
||||
run() {
|
||||
"$ROOT/scripts/e2e-run-spec.sh" "$1" "$2"
|
||||
"$APP_DIR/scripts/e2e-run-spec.sh" "$1" "$2"
|
||||
}
|
||||
|
||||
run "test/e2e/specs/login-flow.spec.ts" "login"
|
||||
@@ -3,7 +3,7 @@
|
||||
# Run a single WebDriverIO E2E spec (Appium mac2 + mock server in spec).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/e2e-run-spec.sh test/e2e/specs/login-flow.spec.ts [log-suffix]
|
||||
# ./app/scripts/e2e-run-spec.sh test/e2e/specs/login-flow.spec.ts [log-suffix]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
@@ -14,12 +14,41 @@ APPIUM_PORT="${APPIUM_PORT:-4723}"
|
||||
E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP_DIR="$REPO_ROOT/app"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$APP_DIR/.." && pwd)"
|
||||
cd "$APP_DIR"
|
||||
# shellcheck source=/dev/null
|
||||
source "$SCRIPT_DIR/e2e-resolve-node-appium.sh"
|
||||
|
||||
CREATED_TEMP_WORKSPACE=""
|
||||
APPIUM_PID=""
|
||||
if [ -z "${OPENHUMAN_WORKSPACE:-}" ]; then
|
||||
OPENHUMAN_WORKSPACE="$(mktemp -d)"
|
||||
CREATED_TEMP_WORKSPACE="$OPENHUMAN_WORKSPACE"
|
||||
export OPENHUMAN_WORKSPACE
|
||||
echo "Using temporary OPENHUMAN_WORKSPACE: $OPENHUMAN_WORKSPACE"
|
||||
else
|
||||
echo "Using OPENHUMAN_WORKSPACE from environment: $OPENHUMAN_WORKSPACE"
|
||||
fi
|
||||
|
||||
if [ "${OPENHUMAN_SERVICE_MOCK:-0}" = "1" ] && [ -z "${OPENHUMAN_SERVICE_MOCK_STATE_FILE:-}" ]; then
|
||||
OPENHUMAN_SERVICE_MOCK_STATE_FILE="$OPENHUMAN_WORKSPACE/service-mock-state.json"
|
||||
export OPENHUMAN_SERVICE_MOCK_STATE_FILE
|
||||
echo "Using OPENHUMAN_SERVICE_MOCK_STATE_FILE: $OPENHUMAN_SERVICE_MOCK_STATE_FILE"
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$APPIUM_PID" ]; then
|
||||
echo "Stopping Appium (pid $APPIUM_PID)..."
|
||||
kill "$APPIUM_PID" 2>/dev/null || true
|
||||
wait "$APPIUM_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "$CREATED_TEMP_WORKSPACE" ]; then
|
||||
rm -rf "$CREATED_TEMP_WORKSPACE"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
|
||||
export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
|
||||
|
||||
@@ -48,13 +77,6 @@ echo " Appium logs: $APPIUM_LOG"
|
||||
"$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 &
|
||||
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
|
||||
|
||||
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."
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run E2E Telegram integration flow tests only. See scripts/e2e-run-spec.sh.
|
||||
# Run E2E Telegram integration flow tests only. See app/scripts/e2e-run-spec.sh.
|
||||
set -euo pipefail
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/telegram-flow.spec.ts" "telegram"
|
||||
@@ -79,3 +79,8 @@ pub async fn core_rpc_relay(
|
||||
|
||||
crate::core_rpc::call::<Value>(&request.method, request.params).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn core_rpc_url() -> String {
|
||||
crate::core_rpc::resolved_rpc_url()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ fn rpc_url() -> String {
|
||||
std::env::var("OPENHUMAN_CORE_RPC_URL").unwrap_or_else(|_| DEFAULT_CORE_RPC_URL.to_string())
|
||||
}
|
||||
|
||||
pub fn resolved_rpc_url() -> String {
|
||||
rpc_url()
|
||||
}
|
||||
|
||||
pub async fn call<T: DeserializeOwned>(
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
|
||||
+33
-61
@@ -18,13 +18,11 @@ use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use commands::*;
|
||||
use rand::TryRngCore;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Emitter, Manager, RunEvent};
|
||||
use tokio::{
|
||||
fs,
|
||||
time::{interval, Duration},
|
||||
};
|
||||
use tokio::time::{interval, Duration};
|
||||
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
@@ -314,66 +312,45 @@ fn is_daemon_mode() -> bool {
|
||||
std::env::args().any(|arg| arg == "daemon" || arg == "--daemon")
|
||||
}
|
||||
|
||||
/// Watch daemon health file and bridge changes to frontend Tauri events
|
||||
async fn watch_daemon_health_file(app_handle: AppHandle, data_dir: PathBuf) {
|
||||
let state_file = data_dir.join("daemon_state.json");
|
||||
/// Poll core RPC health and bridge updates to frontend Tauri events.
|
||||
async fn watch_daemon_health_rpc(app_handle: AppHandle) {
|
||||
let mut interval = interval(Duration::from_secs(2));
|
||||
let mut last_modified: Option<std::time::SystemTime> = None;
|
||||
let mut last_snapshot: Option<serde_json::Value> = None;
|
||||
let mut had_error = false;
|
||||
|
||||
log::info!(
|
||||
"[openhuman] Watching daemon health file: {}",
|
||||
state_file.display()
|
||||
);
|
||||
log::info!("[openhuman] Watching daemon health via core RPC (openhuman.health_snapshot)");
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Check if file exists and was modified
|
||||
if let Ok(metadata) = fs::metadata(&state_file).await {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if last_modified.map_or(true, |last| modified > last) {
|
||||
last_modified = Some(modified);
|
||||
match crate::core_rpc::call::<serde_json::Value>("openhuman.health_snapshot", json!({})).await
|
||||
{
|
||||
Ok(raw_payload) => {
|
||||
// RpcOutcome may be wrapped as {"result": {...}, "logs": [...]}; normalize to snapshot.
|
||||
let snapshot = raw_payload
|
||||
.get("result")
|
||||
.cloned()
|
||||
.unwrap_or(raw_payload);
|
||||
|
||||
// Read and parse health data
|
||||
if let Ok(content) = fs::read_to_string(&state_file).await {
|
||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&content)
|
||||
{
|
||||
log::debug!(
|
||||
"[openhuman] Broadcasting health event from file: {:?}",
|
||||
json_value
|
||||
);
|
||||
|
||||
// Emit Tauri event to frontend (same as internal daemon)
|
||||
if let Err(e) = app_handle.emit("openhuman:health", &json_value) {
|
||||
log::error!(
|
||||
"[openhuman] Failed to emit health event from file: {}",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[openhuman] Health event emitted successfully from file"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"[openhuman] Failed to parse health file as JSON: {}",
|
||||
state_file.display()
|
||||
);
|
||||
}
|
||||
if last_snapshot.as_ref() != Some(&snapshot) {
|
||||
if let Err(e) = app_handle.emit("openhuman:health", &snapshot) {
|
||||
log::error!("[openhuman] Failed to emit health event from RPC: {e}");
|
||||
} else {
|
||||
log::debug!(
|
||||
"[openhuman] Failed to read health file: {}",
|
||||
state_file.display()
|
||||
);
|
||||
last_snapshot = Some(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
if had_error {
|
||||
had_error = false;
|
||||
log::info!("[openhuman] Health RPC polling recovered");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if !had_error {
|
||||
had_error = true;
|
||||
log::debug!("[openhuman] Health RPC not ready yet: {e}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist yet - external daemon may not be writing yet
|
||||
log::debug!(
|
||||
"[openhuman] Health file not found yet: {}",
|
||||
state_file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -524,17 +501,11 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge external daemon health file and ensure core background service.
|
||||
// Bridge daemon health via core RPC and ensure core background service.
|
||||
{
|
||||
let data_dir = app.path().app_data_dir().unwrap_or_else(|_| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".openhuman")
|
||||
});
|
||||
let app_handle_for_watcher = app.handle().clone();
|
||||
let data_dir_clone = data_dir.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
watch_daemon_health_file(app_handle_for_watcher, data_dir_clone).await;
|
||||
watch_daemon_health_rpc(app_handle_for_watcher).await;
|
||||
});
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match commands::core_relay::ensure_service_managed_core_running().await {
|
||||
@@ -593,6 +564,7 @@ pub fn run() {
|
||||
ai_get_config,
|
||||
ai_refresh_config,
|
||||
core_rpc_relay,
|
||||
core_rpc_url,
|
||||
show_window,
|
||||
hide_window,
|
||||
toggle_window,
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": ["../../skills/skills", "../../src/openhuman/agent/prompts"],
|
||||
"resources": ["../../src/openhuman/agent/prompts"],
|
||||
"externalBin": ["binaries/openhuman"],
|
||||
"createUpdaterArtifacts": true,
|
||||
"macOS": {
|
||||
|
||||
+19
-13
@@ -4,6 +4,7 @@ import { HashRouter as Router } from 'react-router-dom';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
|
||||
import AppRoutes from './AppRoutes';
|
||||
import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
|
||||
import ErrorFallbackScreen from './components/ErrorFallbackScreen';
|
||||
import MiniSidebar from './components/MiniSidebar';
|
||||
import AIProvider from './providers/AIProvider';
|
||||
@@ -27,31 +28,36 @@ function App() {
|
||||
<PersistGate
|
||||
loading={null}
|
||||
persistor={persistor}
|
||||
onBeforeLift={async () => {
|
||||
onBeforeLift={() => {
|
||||
const token = store.getState().auth.token;
|
||||
console.info('[memory] PersistGate onBeforeLift: token_present=%s', !!token);
|
||||
if (token) await syncMemoryClientToken(token);
|
||||
if (token) {
|
||||
// Do not block initial render on core/memory availability.
|
||||
void syncMemoryClientToken(token);
|
||||
}
|
||||
}}>
|
||||
<UserProvider>
|
||||
<SocketProvider>
|
||||
<AIProvider>
|
||||
<SkillProvider>
|
||||
<Router>
|
||||
<div className="relative h-screen flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<MiniSidebar />
|
||||
<div className="flex flex-col flex-1 relative overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
<div className="pointer-events-none flex-shrink-0 flex justify-center z-50">
|
||||
<div className="w-full px-3 py-1.5 text-[9px] uppercase tracking-[0.18em] text-white/40 text-center bg-[#000]">
|
||||
OpenHuman is in early beta
|
||||
<ServiceBlockingGate>
|
||||
<div className="relative h-screen flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<MiniSidebar />
|
||||
<div className="flex flex-col flex-1 relative overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
<div className="pointer-events-none flex-shrink-0 flex justify-center z-50">
|
||||
<div className="w-full px-3 py-1.5 text-[9px] uppercase tracking-[0.18em] text-white/40 text-center bg-[#000]">
|
||||
OpenHuman is in early beta
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServiceBlockingGate>
|
||||
</Router>
|
||||
</SkillProvider>
|
||||
</AIProvider>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import DefaultRedirect from './components/DefaultRedirect';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import PublicRoute from './components/PublicRoute';
|
||||
import RouteLoadingScreen from './components/RouteLoadingScreen';
|
||||
import Agents from './pages/Agents';
|
||||
import Conversations from './pages/Conversations';
|
||||
import Home from './pages/Home';
|
||||
@@ -34,7 +35,7 @@ const OnboardingRoute = ({
|
||||
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
|
||||
const shouldSkipOnboarding = isOnboarded || hasWorkspaceOnboardingFlag;
|
||||
|
||||
if (isWorkspaceFlagLoading) return null;
|
||||
if (isWorkspaceFlagLoading) return <RouteLoadingScreen label="Loading workspace..." />;
|
||||
if (shouldSkipOnboarding && !hasEncryptionKey) return <Navigate to="/mnemonic" replace />;
|
||||
if (shouldSkipOnboarding) return <Navigate to="/home" replace />;
|
||||
return <Onboarding />;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import RouteLoadingScreen from './RouteLoadingScreen';
|
||||
|
||||
/**
|
||||
* Default redirect component that routes users based on their auth status.
|
||||
@@ -9,6 +10,11 @@ import { useAppSelector } from '../store/hooks';
|
||||
*/
|
||||
const DefaultRedirect = () => {
|
||||
const token = useAppSelector(state => state.auth.token);
|
||||
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
|
||||
|
||||
if (!isAuthBootstrapComplete) {
|
||||
return <RouteLoadingScreen />;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
return <Navigate to="/home" replace />;
|
||||
|
||||
@@ -152,9 +152,12 @@ const MiniSidebar = () => {
|
||||
}).length;
|
||||
});
|
||||
|
||||
// Hide sidebar when not authenticated or on public/onboarding routes
|
||||
const hiddenPaths = ['/', '/login', '/onboarding'];
|
||||
if (!token || hiddenPaths.includes(location.pathname)) {
|
||||
// Hide sidebar on public/setup routes and when not authenticated.
|
||||
const hiddenPaths = ['/', '/login', '/onboarding', '/mnemonic'];
|
||||
if (
|
||||
!token ||
|
||||
hiddenPaths.some(path => location.pathname === path || location.pathname.startsWith(`${path}/`))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { selectIsOnboarded } from '../store/authSelectors';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import RouteLoadingScreen from './RouteLoadingScreen';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
@@ -20,8 +21,13 @@ const ProtectedRoute = ({
|
||||
redirectTo,
|
||||
}: ProtectedRouteProps) => {
|
||||
const token = useAppSelector(state => state.auth.token);
|
||||
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
|
||||
const isOnboarded = useAppSelector(selectIsOnboarded);
|
||||
|
||||
if (!isAuthBootstrapComplete) {
|
||||
return <RouteLoadingScreen />;
|
||||
}
|
||||
|
||||
// If auth is required but user is not logged in
|
||||
if (requireAuth && !token) {
|
||||
return <Navigate to={redirectTo || '/'} replace />;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import RouteLoadingScreen from './RouteLoadingScreen';
|
||||
|
||||
interface PublicRouteProps {
|
||||
children: React.ReactNode;
|
||||
@@ -13,6 +14,11 @@ interface PublicRouteProps {
|
||||
*/
|
||||
const PublicRoute = ({ children, redirectTo }: PublicRouteProps) => {
|
||||
const token = useAppSelector(state => state.auth.token);
|
||||
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
|
||||
|
||||
if (!isAuthBootstrapComplete) {
|
||||
return <RouteLoadingScreen />;
|
||||
}
|
||||
|
||||
// If user is logged in, always go to home.
|
||||
// Home itself will redirect to onboarding if needed.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
interface RouteLoadingScreenProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const RouteLoadingScreen = ({ label = 'Initializing OpenHuman...' }: RouteLoadingScreenProps) => {
|
||||
return (
|
||||
<div className="h-full min-h-[280px] w-full flex items-center justify-center">
|
||||
<div className="rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/80">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteLoadingScreen;
|
||||
@@ -20,7 +20,12 @@ describe('ProtectedRoute', () => {
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: { token: 'valid-jwt', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
auth: {
|
||||
token: 'valid-jwt',
|
||||
isAuthBootstrapComplete: true,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -44,7 +49,12 @@ describe('ProtectedRoute', () => {
|
||||
{
|
||||
initialEntries: ['/dashboard'],
|
||||
preloadedState: {
|
||||
auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
auth: {
|
||||
token: null,
|
||||
isAuthBootstrapComplete: true,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -69,7 +79,12 @@ describe('ProtectedRoute', () => {
|
||||
{
|
||||
initialEntries: ['/dashboard'],
|
||||
preloadedState: {
|
||||
auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
auth: {
|
||||
token: null,
|
||||
isAuthBootstrapComplete: true,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -93,7 +108,12 @@ describe('ProtectedRoute', () => {
|
||||
{
|
||||
initialEntries: ['/home'],
|
||||
preloadedState: {
|
||||
auth: { token: 'valid-jwt', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
auth: {
|
||||
token: 'valid-jwt',
|
||||
isAuthBootstrapComplete: true,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
user: { user: { _id: 'u1' }, isLoading: false, error: null },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,7 +20,12 @@ describe('PublicRoute', () => {
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
auth: {
|
||||
token: null,
|
||||
isAuthBootstrapComplete: true,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -43,7 +48,12 @@ describe('PublicRoute', () => {
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: { token: 'jwt-token', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
auth: {
|
||||
token: 'jwt-token',
|
||||
isAuthBootstrapComplete: true,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -67,7 +77,12 @@ describe('PublicRoute', () => {
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: { token: 'jwt-token', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
auth: {
|
||||
token: 'jwt-token',
|
||||
isAuthBootstrapComplete: true,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
isTauri,
|
||||
openhumanAgentServerStatus,
|
||||
openhumanServiceInstall,
|
||||
openhumanServiceStart,
|
||||
openhumanServiceStatus,
|
||||
openhumanServiceStop,
|
||||
openhumanServiceUninstall,
|
||||
type ServiceState,
|
||||
} from '../../utils/tauriCommands';
|
||||
|
||||
interface ServiceBlockingGateProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
type GateStatus = 'checking' | 'ready' | 'blocked';
|
||||
const SERVICE_GATE_POLL_MS = 3000;
|
||||
type RefreshOptions = { showChecking?: boolean; clearError?: boolean };
|
||||
|
||||
const normalizeServiceState = (state: ServiceState | undefined): string => {
|
||||
if (!state) return 'Unknown';
|
||||
if (typeof state === 'string') return state;
|
||||
if ('Unknown' in state) return `Unknown(${state.Unknown})`;
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
|
||||
const [gateStatus, setGateStatus] = useState<GateStatus>('checking');
|
||||
const [serviceStateText, setServiceStateText] = useState('Unknown');
|
||||
const [agentRunning, setAgentRunning] = useState(false);
|
||||
const [isOperating, setIsOperating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshStatus = useCallback(async (options: RefreshOptions = {}) => {
|
||||
const { showChecking = false, clearError = false } = options;
|
||||
if (!isTauri()) {
|
||||
console.info('[ServiceBlockingGate] Non-Tauri environment detected; gate is ready');
|
||||
setGateStatus('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
if (clearError) {
|
||||
setError(null);
|
||||
}
|
||||
if (showChecking) {
|
||||
setGateStatus('checking');
|
||||
}
|
||||
console.info('[ServiceBlockingGate] Refreshing service + agent status');
|
||||
|
||||
try {
|
||||
const [service, agent] = await Promise.all([
|
||||
openhumanServiceStatus(),
|
||||
openhumanAgentServerStatus(),
|
||||
]);
|
||||
const serviceState = service?.result?.state;
|
||||
const normalized = normalizeServiceState(serviceState);
|
||||
const serviceRunning = normalized === 'Running';
|
||||
const agentIsRunning = !!agent?.result?.running;
|
||||
|
||||
setServiceStateText(prev => (prev === normalized ? prev : normalized));
|
||||
setAgentRunning(prev => (prev === agentIsRunning ? prev : agentIsRunning));
|
||||
setGateStatus(prev => {
|
||||
const next = serviceRunning && agentIsRunning ? 'ready' : 'blocked';
|
||||
return prev === next ? prev : next;
|
||||
});
|
||||
setError(prev => (prev ? null : prev));
|
||||
console.info('[ServiceBlockingGate] Status refreshed', {
|
||||
serviceState: normalized,
|
||||
agentRunning: agentIsRunning,
|
||||
nextGateStatus: serviceRunning && agentIsRunning ? 'ready' : 'blocked',
|
||||
});
|
||||
} catch (err) {
|
||||
setServiceStateText('Unknown');
|
||||
setAgentRunning(false);
|
||||
setGateStatus('blocked');
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
console.error('[ServiceBlockingGate] Failed to refresh status', { error: message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus({ showChecking: true });
|
||||
}, [refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info('[ServiceBlockingGate] Starting periodic health polling', {
|
||||
pollMs: SERVICE_GATE_POLL_MS,
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshStatus();
|
||||
}, SERVICE_GATE_POLL_MS);
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
console.info('[ServiceBlockingGate] App visible; forcing immediate status refresh');
|
||||
void refreshStatus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
|
||||
return () => {
|
||||
console.info('[ServiceBlockingGate] Stopping periodic health polling');
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [refreshStatus]);
|
||||
|
||||
const installed = useMemo(() => serviceStateText !== 'NotInstalled', [serviceStateText]);
|
||||
const serviceRunning = useMemo(() => serviceStateText === 'Running', [serviceStateText]);
|
||||
|
||||
const runOperation = useCallback(
|
||||
async (op: () => Promise<unknown>) => {
|
||||
const opName = op.name || 'anonymous-operation';
|
||||
console.info('[ServiceBlockingGate] Running operation', { operation: opName });
|
||||
setIsOperating(true);
|
||||
setError(null);
|
||||
try {
|
||||
await op();
|
||||
console.info('[ServiceBlockingGate] Operation completed', { operation: opName });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
console.error('[ServiceBlockingGate] Operation failed', {
|
||||
operation: opName,
|
||||
error: message,
|
||||
});
|
||||
} finally {
|
||||
setIsOperating(false);
|
||||
await refreshStatus();
|
||||
}
|
||||
},
|
||||
[refreshStatus]
|
||||
);
|
||||
|
||||
const restartService = useCallback(async () => {
|
||||
console.info('[ServiceBlockingGate] Restart requested: stop -> start');
|
||||
await openhumanServiceStop();
|
||||
await openhumanServiceStart();
|
||||
}, []);
|
||||
|
||||
if (gateStatus === 'ready') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-[#0a0d12] text-white px-6">
|
||||
<div className="w-full max-w-xl rounded-2xl border border-white/15 bg-black/30 p-6 space-y-4">
|
||||
<h1 className="text-xl font-semibold">OpenHuman Service Required</h1>
|
||||
<p className="text-sm text-white/70">
|
||||
The desktop service must be installed and running before the app can continue.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 p-3">
|
||||
<div className="text-white/60">Service</div>
|
||||
<div className="font-medium">{serviceStateText}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 p-3">
|
||||
<div className="text-white/60">Agent Server</div>
|
||||
<div className="font-medium">{agentRunning ? 'Running' : 'Not Running'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-500/40 bg-red-900/20 p-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
disabled={isOperating || installed}
|
||||
onClick={() => void runOperation(() => openhumanServiceInstall())}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
Install Service
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating || !installed || (serviceRunning && agentRunning)}
|
||||
onClick={() => void runOperation(() => openhumanServiceStart())}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-green-600 hover:bg-green-700 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
Start Service
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating || !installed || !serviceRunning}
|
||||
onClick={() => void runOperation(() => openhumanServiceStop())}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-red-600 hover:bg-red-700 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
Stop Service
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating || !installed}
|
||||
onClick={() => void runOperation(restartService)}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-cyan-700 hover:bg-cyan-800 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
Restart Service
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating || !installed}
|
||||
onClick={() => void runOperation(() => openhumanServiceUninstall())}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-amber-700 hover:bg-amber-800 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
Uninstall Service
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating}
|
||||
onClick={() => void refreshStatus({ showChecking: true, clearError: true })}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-gray-700 hover:bg-gray-600 disabled:bg-gray-700/60 disabled:text-gray-400">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceBlockingGate;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, type Mock } from 'vitest';
|
||||
|
||||
import * as tauriCommands from '../../../utils/tauriCommands';
|
||||
import ServiceBlockingGate from '../ServiceBlockingGate';
|
||||
|
||||
describe('ServiceBlockingGate', () => {
|
||||
const mockIsTauri = tauriCommands.isTauri as Mock;
|
||||
const mockServiceStatus = tauriCommands.openhumanServiceStatus as Mock;
|
||||
const mockAgentStatus = tauriCommands.openhumanAgentServerStatus as Mock;
|
||||
const mockInstall = tauriCommands.openhumanServiceInstall as Mock;
|
||||
const mockStart = tauriCommands.openhumanServiceStart as Mock;
|
||||
|
||||
it('renders children directly outside Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
<div>App Content</div>
|
||||
</ServiceBlockingGate>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows blocking screen when service is not installed', async () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] });
|
||||
mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
|
||||
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
<div>App Content</div>
|
||||
</ServiceBlockingGate>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('OpenHuman Service Required')).toBeInTheDocument());
|
||||
expect(screen.queryByText('App Content')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('NotInstalled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('runs install and start actions from blocker', async () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] });
|
||||
mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
|
||||
mockInstall.mockResolvedValue({ result: { state: 'Stopped' }, logs: [] });
|
||||
mockStart.mockResolvedValue({ result: { state: 'Running' }, logs: [] });
|
||||
|
||||
render(
|
||||
<ServiceBlockingGate>
|
||||
<div>App Content</div>
|
||||
</ServiceBlockingGate>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('OpenHuman Service Required')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install Service' }));
|
||||
await waitFor(() => expect(mockInstall).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getBackendUrl } from '../../services/backendUrl';
|
||||
import type { OAuthProviderConfig } from '../../types/oauth';
|
||||
import { IS_DEV } from '../../utils/config';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
@@ -23,23 +24,26 @@ const OAuthProviderButton = ({
|
||||
|
||||
console.log(`Starting ${provider.name} OAuth login`, isTauri());
|
||||
|
||||
if (IS_DEV) {
|
||||
console.log(`[dev] OAuth debug mode enabled. OAuth URL: ${provider.loginUrl}`);
|
||||
console.log('[dev] In debug mode, OAuth will return JSON response instead of redirect.');
|
||||
console.log(
|
||||
'[dev] After OAuth completion, copy the loginToken and use: window.__simulateDeepLink("openhuman://auth?token=YOUR_TOKEN")'
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const backendUrl = await getBackendUrl();
|
||||
const loginUrl = `${backendUrl}/auth/${provider.id}/login${IS_DEV ? '?responseType=json' : ''}`;
|
||||
|
||||
if (IS_DEV) {
|
||||
console.log(`[dev] OAuth debug mode enabled. OAuth URL: ${loginUrl}`);
|
||||
console.log('[dev] In debug mode, OAuth will return JSON response instead of redirect.');
|
||||
console.log(
|
||||
'[dev] After OAuth completion, copy the loginToken and use: window.__simulateDeepLink("openhuman://auth?token=YOUR_TOKEN")'
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop (Tauri): use system browser → backend OAuth → deep link back to app
|
||||
if (isTauri()) {
|
||||
await openUrl(provider.loginUrl);
|
||||
await openUrl(loginUrl);
|
||||
} else {
|
||||
// Web fallback: direct OAuth flow in current window
|
||||
window.location.href = provider.loginUrl;
|
||||
window.location.href = loginUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to initiate ${provider.name} OAuth login:`, error);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* OAuth provider configurations with brand colors and icons
|
||||
*/
|
||||
import type { OAuthProviderConfig } from '../../types/oauth';
|
||||
import { BACKEND_URL, IS_DEV } from '../../utils/config';
|
||||
|
||||
// Provider Icons
|
||||
const GoogleIcon = ({ className = '' }: { className?: string }) => (
|
||||
@@ -52,7 +51,6 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
|
||||
color: 'bg-white border border-gray-200',
|
||||
hoverColor: 'hover:bg-gray-50 hover:border-gray-300',
|
||||
textColor: 'text-gray-900',
|
||||
loginUrl: `${BACKEND_URL}/auth/google/login?${IS_DEV ? 'responseType=json' : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
@@ -61,7 +59,6 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
|
||||
color: 'bg-gray-900 border border-gray-800',
|
||||
hoverColor: 'hover:bg-gray-800 hover:border-gray-700',
|
||||
textColor: 'text-white',
|
||||
loginUrl: `${BACKEND_URL}/auth/github/login?${IS_DEV ? 'responseType=json' : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'twitter',
|
||||
@@ -70,7 +67,6 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
|
||||
color: 'bg-black border border-gray-800',
|
||||
hoverColor: 'hover:bg-gray-900 hover:border-gray-700',
|
||||
textColor: 'text-white',
|
||||
loginUrl: `${BACKEND_URL}/auth/twitter/login?${IS_DEV ? 'responseType=json' : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
@@ -79,7 +75,6 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
|
||||
color: 'bg-indigo-600 border border-indigo-500',
|
||||
hoverColor: 'hover:bg-indigo-700 hover:border-indigo-600',
|
||||
textColor: 'text-white',
|
||||
loginUrl: `${BACKEND_URL}/auth/discord/login?${IS_DEV ? 'responseType=json' : ''}`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const SettingsHome = () => {
|
||||
} catch (err) {
|
||||
console.warn('[Settings] Rust logout failed:', err);
|
||||
}
|
||||
window.location.hash = '/login';
|
||||
window.location.hash = '/';
|
||||
};
|
||||
|
||||
const clearAllAppData = async () => {
|
||||
@@ -47,7 +47,7 @@ const SettingsHome = () => {
|
||||
}
|
||||
|
||||
// Complete reset - redirect to login for fresh start
|
||||
window.location.hash = '/login';
|
||||
window.location.hash = '/';
|
||||
};
|
||||
|
||||
const handleLogoutAndClearData = async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
isTauri,
|
||||
type LocalAiAssetsStatus,
|
||||
type LocalAiDownloadsProgress,
|
||||
type LocalAiEmbeddingResult,
|
||||
type LocalAiSpeechResult,
|
||||
type LocalAiStatus,
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
type LocalAiTtsResult,
|
||||
openhumanLocalAiAssetsStatus,
|
||||
openhumanLocalAiDownload,
|
||||
openhumanLocalAiDownloadAllAssets,
|
||||
openhumanLocalAiDownloadAsset,
|
||||
openhumanLocalAiDownloadsProgress,
|
||||
openhumanLocalAiEmbed,
|
||||
openhumanLocalAiPrompt,
|
||||
openhumanLocalAiStatus,
|
||||
@@ -77,6 +79,12 @@ const progressFromStatus = (status: LocalAiStatus | null): number => {
|
||||
}
|
||||
};
|
||||
|
||||
const progressFromDownloads = (downloads: LocalAiDownloadsProgress | null): number | null => {
|
||||
if (!downloads) return null;
|
||||
if (typeof downloads.progress !== 'number') return null;
|
||||
return Math.max(0, Math.min(1, downloads.progress));
|
||||
};
|
||||
|
||||
const formatBytes = (bytes?: number | null): string => {
|
||||
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return '0 B';
|
||||
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
||||
@@ -104,6 +112,7 @@ const LocalModelPanel = () => {
|
||||
const { navigateBack } = useSettingsNavigation();
|
||||
const [status, setStatus] = useState<LocalAiStatus | null>(null);
|
||||
const [assets, setAssets] = useState<LocalAiAssetsStatus | null>(null);
|
||||
const [downloads, setDownloads] = useState<LocalAiDownloadsProgress | null>(null);
|
||||
const [statusError, setStatusError] = useState<string>('');
|
||||
const [isTriggeringDownload, setIsTriggeringDownload] = useState(false);
|
||||
const [assetDownloadBusy, setAssetDownloadBusy] = useState<Record<string, boolean>>({});
|
||||
@@ -139,37 +148,44 @@ const LocalModelPanel = () => {
|
||||
const [ttsOutput, setTtsOutput] = useState<LocalAiTtsResult | null>(null);
|
||||
const [isTtsLoading, setIsTtsLoading] = useState(false);
|
||||
|
||||
const progress = useMemo(() => progressFromStatus(status), [status]);
|
||||
const progress = useMemo(() => {
|
||||
const downloadProgress = progressFromDownloads(downloads);
|
||||
if (downloadProgress != null) return downloadProgress;
|
||||
return progressFromStatus(status);
|
||||
}, [downloads, status]);
|
||||
const isIndeterminateDownload =
|
||||
status?.state === 'downloading' && typeof status.download_progress !== 'number';
|
||||
(downloads?.state ?? status?.state) === 'downloading' &&
|
||||
typeof downloads?.progress !== 'number' &&
|
||||
typeof status?.download_progress !== 'number';
|
||||
const downloadedBytes = downloads?.downloaded_bytes ?? status?.downloaded_bytes;
|
||||
const totalBytes = downloads?.total_bytes ?? status?.total_bytes;
|
||||
const speedBps = downloads?.speed_bps ?? status?.download_speed_bps;
|
||||
const etaSeconds = downloads?.eta_seconds ?? status?.eta_seconds;
|
||||
const downloadedText =
|
||||
typeof status?.downloaded_bytes === 'number'
|
||||
? `${formatBytes(status.downloaded_bytes)}${typeof status?.total_bytes === 'number' ? ` / ${formatBytes(status.total_bytes)}` : ''}`
|
||||
typeof downloadedBytes === 'number'
|
||||
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
|
||||
: '';
|
||||
const speedText =
|
||||
typeof status?.download_speed_bps === 'number' && status.download_speed_bps > 0
|
||||
? `${formatBytes(status.download_speed_bps)}/s`
|
||||
: '';
|
||||
const etaText = formatEta(status?.eta_seconds);
|
||||
typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : '';
|
||||
const etaText = formatEta(etaSeconds);
|
||||
|
||||
const loadStatus = async () => {
|
||||
if (!isTauri()) {
|
||||
setStatusError('Local model tools are available only in Tauri desktop builds.');
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await openhumanLocalAiStatus();
|
||||
const assetResponse = await openhumanLocalAiAssetsStatus();
|
||||
setStatus(response.result);
|
||||
setAssets(assetResponse.result);
|
||||
const [statusResponse, assetsResponse, downloadsResponse] = await Promise.all([
|
||||
openhumanLocalAiStatus(),
|
||||
openhumanLocalAiAssetsStatus(),
|
||||
openhumanLocalAiDownloadsProgress(),
|
||||
]);
|
||||
setStatus(statusResponse.result);
|
||||
setAssets(assetsResponse.result);
|
||||
setDownloads(downloadsResponse.result);
|
||||
setStatusError('');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to read local model status';
|
||||
setStatusError(message);
|
||||
setStatus(null);
|
||||
setAssets(null);
|
||||
setDownloads(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,11 +198,11 @@ const LocalModelPanel = () => {
|
||||
}, []);
|
||||
|
||||
const triggerDownload = async (force: boolean) => {
|
||||
if (!isTauri()) return;
|
||||
setIsTriggeringDownload(true);
|
||||
setStatusError('');
|
||||
try {
|
||||
await openhumanLocalAiDownload(force);
|
||||
await openhumanLocalAiDownloadAllAssets(force);
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
const message =
|
||||
@@ -198,7 +214,7 @@ const LocalModelPanel = () => {
|
||||
};
|
||||
|
||||
const runSummaryTest = async () => {
|
||||
if (!summaryInput.trim() || !isTauri()) return;
|
||||
if (!summaryInput.trim()) return;
|
||||
setIsSummaryLoading(true);
|
||||
setSummaryOutput('');
|
||||
setStatusError('');
|
||||
@@ -215,7 +231,7 @@ const LocalModelPanel = () => {
|
||||
};
|
||||
|
||||
const runSuggestTest = async () => {
|
||||
if (!suggestInput.trim() || !isTauri()) return;
|
||||
if (!suggestInput.trim()) return;
|
||||
setIsSuggestLoading(true);
|
||||
setSuggestions([]);
|
||||
setStatusError('');
|
||||
@@ -232,7 +248,7 @@ const LocalModelPanel = () => {
|
||||
};
|
||||
|
||||
const runPromptTest = async () => {
|
||||
if (!promptInput.trim() || !isTauri()) return;
|
||||
if (!promptInput.trim()) return;
|
||||
setIsPromptLoading(true);
|
||||
setPromptOutput('');
|
||||
setStatusError('');
|
||||
@@ -249,7 +265,7 @@ const LocalModelPanel = () => {
|
||||
};
|
||||
|
||||
const runVisionTest = async () => {
|
||||
if (!visionPromptInput.trim() || !visionImageInput.trim() || !isTauri()) return;
|
||||
if (!visionPromptInput.trim() || !visionImageInput.trim()) return;
|
||||
setIsVisionLoading(true);
|
||||
setVisionOutput('');
|
||||
setStatusError('');
|
||||
@@ -270,7 +286,7 @@ const LocalModelPanel = () => {
|
||||
};
|
||||
|
||||
const runEmbeddingTest = async () => {
|
||||
if (!embeddingInput.trim() || !isTauri()) return;
|
||||
if (!embeddingInput.trim()) return;
|
||||
setIsEmbeddingLoading(true);
|
||||
setEmbeddingOutput(null);
|
||||
setStatusError('');
|
||||
@@ -291,7 +307,7 @@ const LocalModelPanel = () => {
|
||||
};
|
||||
|
||||
const runTranscribeTest = async () => {
|
||||
if (!audioPathInput.trim() || !isTauri()) return;
|
||||
if (!audioPathInput.trim()) return;
|
||||
setIsTranscribeLoading(true);
|
||||
setTranscribeOutput(null);
|
||||
setStatusError('');
|
||||
@@ -308,7 +324,7 @@ const LocalModelPanel = () => {
|
||||
};
|
||||
|
||||
const runTtsTest = async () => {
|
||||
if (!ttsInput.trim() || !isTauri()) return;
|
||||
if (!ttsInput.trim()) return;
|
||||
setIsTtsLoading(true);
|
||||
setTtsOutput(null);
|
||||
setStatusError('');
|
||||
@@ -330,7 +346,6 @@ const LocalModelPanel = () => {
|
||||
const triggerAssetDownload = async (
|
||||
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
|
||||
) => {
|
||||
if (!isTauri()) return;
|
||||
setAssetDownloadBusy(prev => ({ ...prev, [capability]: true }));
|
||||
setStatusError('');
|
||||
try {
|
||||
@@ -364,7 +379,7 @@ const LocalModelPanel = () => {
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">State</span>
|
||||
<span className={`font-medium ${statusTone(status?.state ?? 'idle')}`}>
|
||||
{status ? statusLabel(status.state) : 'Unavailable'}
|
||||
{status ? statusLabel(downloads?.state ?? status.state) : 'Unavailable'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -436,13 +451,13 @@ const LocalModelPanel = () => {
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => void triggerDownload(false)}
|
||||
disabled={isTriggeringDownload || !isTauri()}
|
||||
disabled={isTriggeringDownload}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white">
|
||||
{isTriggeringDownload ? 'Triggering...' : 'Bootstrap / Resume'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void triggerDownload(true)}
|
||||
disabled={isTriggeringDownload || !isTauri()}
|
||||
disabled={isTriggeringDownload}
|
||||
className="px-3 py-1.5 text-xs rounded-md border border-gray-600 hover:border-gray-500 disabled:opacity-60 text-stone-200">
|
||||
Force Re-bootstrap
|
||||
</button>
|
||||
@@ -475,7 +490,7 @@ const LocalModelPanel = () => {
|
||||
)}
|
||||
<button
|
||||
onClick={() => void triggerAssetDownload(key)}
|
||||
disabled={assetDownloadBusy[key] || !isTauri()}
|
||||
disabled={assetDownloadBusy[key]}
|
||||
className="mt-2 px-2 py-1 text-[10px] rounded border border-gray-600 hover:border-gray-500 disabled:opacity-60 text-stone-200">
|
||||
{assetDownloadBusy[key] ? 'Downloading...' : 'Download'}
|
||||
</button>
|
||||
@@ -500,7 +515,7 @@ const LocalModelPanel = () => {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void runSummaryTest()}
|
||||
disabled={isSummaryLoading || !summaryInput.trim() || !isTauri()}
|
||||
disabled={isSummaryLoading || !summaryInput.trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 text-white">
|
||||
{isSummaryLoading ? 'Running...' : 'Run Summary Test'}
|
||||
</button>
|
||||
@@ -528,7 +543,7 @@ const LocalModelPanel = () => {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void runSuggestTest()}
|
||||
disabled={isSuggestLoading || !suggestInput.trim() || !isTauri()}
|
||||
disabled={isSuggestLoading || !suggestInput.trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 disabled:opacity-60 text-white">
|
||||
{isSuggestLoading ? 'Running...' : 'Run Suggestion Test'}
|
||||
</button>
|
||||
@@ -572,7 +587,7 @@ const LocalModelPanel = () => {
|
||||
</label>
|
||||
<button
|
||||
onClick={() => void runPromptTest()}
|
||||
disabled={isPromptLoading || !promptInput.trim() || !isTauri()}
|
||||
disabled={isPromptLoading || !promptInput.trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white">
|
||||
{isPromptLoading ? 'Running...' : 'Run Prompt Test'}
|
||||
</button>
|
||||
@@ -605,12 +620,7 @@ const LocalModelPanel = () => {
|
||||
/>
|
||||
<button
|
||||
onClick={() => void runVisionTest()}
|
||||
disabled={
|
||||
isVisionLoading ||
|
||||
!visionPromptInput.trim() ||
|
||||
!visionImageInput.trim() ||
|
||||
!isTauri()
|
||||
}
|
||||
disabled={isVisionLoading || !visionPromptInput.trim() || !visionImageInput.trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white">
|
||||
{isVisionLoading ? 'Running...' : 'Run Vision Test'}
|
||||
</button>
|
||||
@@ -633,7 +643,7 @@ const LocalModelPanel = () => {
|
||||
/>
|
||||
<button
|
||||
onClick={() => void runEmbeddingTest()}
|
||||
disabled={isEmbeddingLoading || !embeddingInput.trim() || !isTauri()}
|
||||
disabled={isEmbeddingLoading || !embeddingInput.trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-teal-600 hover:bg-teal-700 disabled:opacity-60 text-white">
|
||||
{isEmbeddingLoading ? 'Running...' : 'Run Embedding Test'}
|
||||
</button>
|
||||
@@ -658,7 +668,7 @@ const LocalModelPanel = () => {
|
||||
/>
|
||||
<button
|
||||
onClick={() => void runTranscribeTest()}
|
||||
disabled={isTranscribeLoading || !audioPathInput.trim() || !isTauri()}
|
||||
disabled={isTranscribeLoading || !audioPathInput.trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-purple-600 hover:bg-purple-700 disabled:opacity-60 text-white">
|
||||
{isTranscribeLoading ? 'Running...' : 'Run Transcription Test'}
|
||||
</button>
|
||||
@@ -688,7 +698,7 @@ const LocalModelPanel = () => {
|
||||
/>
|
||||
<button
|
||||
onClick={() => void runTtsTest()}
|
||||
disabled={isTtsLoading || !ttsInput.trim() || !isTauri()}
|
||||
disabled={isTtsLoading || !ttsInput.trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-rose-600 hover:bg-rose-700 disabled:opacity-60 text-white">
|
||||
{isTtsLoading ? 'Running...' : 'Run TTS Test'}
|
||||
</button>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { formatRelativeTime, useDaemonHealth } from '../../../hooks/useDaemonHealth';
|
||||
import { BACKEND_URL } from '../../../utils/config';
|
||||
import {
|
||||
isTauri,
|
||||
openhumanAgentChat,
|
||||
@@ -640,7 +639,7 @@ const TauriCommandsPanel = () => {
|
||||
onChange={setApiUrl}
|
||||
error={fieldErrors.apiUrl}
|
||||
type="url"
|
||||
placeholder={BACKEND_URL}
|
||||
placeholder="Resolved by core binary"
|
||||
helpText="REST API origin for your OpenHuman backend (chat at /openai/v1/chat/completions)."
|
||||
validation={
|
||||
!apiUrl
|
||||
|
||||
@@ -17,13 +17,13 @@ import { listen } from '@tauri-apps/api/event';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getBackendUrl } from '../services/backendUrl';
|
||||
import type { RootState } from '../store';
|
||||
import type {
|
||||
ActionableItem,
|
||||
ActionableItemPriority,
|
||||
ActionableItemSource,
|
||||
} from '../types/intelligence';
|
||||
import { BACKEND_URL } from '../utils/config';
|
||||
import { consciousLoopRun, isTauri, memoryQueryNamespace } from '../utils/tauriCommands';
|
||||
|
||||
// ─── Types from conscious_loop.rs (mirrored) ────────────────────────────────
|
||||
@@ -178,7 +178,7 @@ export function useConsciousItems(): UseConsciousItemsResult {
|
||||
const triggerAnalysis = useCallback(async () => {
|
||||
if (!isTauri() || !authToken || isRunning) return;
|
||||
try {
|
||||
await consciousLoopRun(authToken, BACKEND_URL);
|
||||
await consciousLoopRun(authToken, await getBackendUrl());
|
||||
} catch (err) {
|
||||
console.warn('[conscious] Failed to trigger analysis:', err);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ describe('Unified AI Loader', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset persisted mockReturnValue from tests that stub localStorage (e.g. cache hit test).
|
||||
localStorageMock.getItem.mockReset();
|
||||
localStorageMock.setItem.mockReset();
|
||||
localStorageMock.removeItem.mockReset();
|
||||
clearAICache();
|
||||
(loadSoul as any).mockResolvedValue(mockSoulConfig);
|
||||
(loadTools as any).mockResolvedValue(mockToolsConfig);
|
||||
|
||||
@@ -9,8 +9,20 @@ import './polyfills';
|
||||
import { initSentry } from './services/analytics';
|
||||
import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener';
|
||||
|
||||
const ensureDefaultHashRoute = () => {
|
||||
const hash = window.location.hash;
|
||||
if (!hash || hash === '#') {
|
||||
window.location.replace(`${window.location.pathname}${window.location.search}#/`);
|
||||
return;
|
||||
}
|
||||
if (!hash.startsWith('#/')) {
|
||||
window.location.hash = '/';
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize Sentry early (before React renders)
|
||||
initSentry();
|
||||
ensureDefaultHashRoute();
|
||||
|
||||
// Deep link listener — try/catch handles non-Tauri environments
|
||||
setupDesktopDeepLinkListener().catch(err => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { creditsApi, type TeamUsage } from '../services/api/creditsApi';
|
||||
import { inferenceApi, type ModelInfo } from '../services/api/inferenceApi';
|
||||
import { getBackendUrl } from '../services/backendUrl';
|
||||
import {
|
||||
chatCancel,
|
||||
chatSend,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
setSelectedThread,
|
||||
} from '../store/threadSlice';
|
||||
import type { ThreadMessage } from '../types/thread';
|
||||
import { BACKEND_URL } from '../utils/config';
|
||||
import {
|
||||
openhumanAgentChat,
|
||||
openhumanLocalAiTranscribeBytes,
|
||||
@@ -446,7 +446,7 @@ const Conversations = () => {
|
||||
message: trimmed,
|
||||
model: selectedModel,
|
||||
authToken,
|
||||
backendUrl: BACKEND_URL,
|
||||
backendUrl: await getBackendUrl(),
|
||||
messages: chatMessages,
|
||||
notionContext: notionCtx,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { SkillManifest } from '../lib/skills/types';
|
||||
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice';
|
||||
import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { runtimeDiscoverSkills } from '../utils/tauriCommands';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -146,18 +146,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
|
||||
skillManager.registerSkill(manifest);
|
||||
}
|
||||
|
||||
// Auto-start skill specified in DEV_AUTO_LOAD_SKILL env variable (dev only)
|
||||
if (DEV_AUTO_LOAD_SKILL) {
|
||||
const autoLoadManifest = manifests.find(m => m.id === DEV_AUTO_LOAD_SKILL);
|
||||
if (autoLoadManifest) {
|
||||
try {
|
||||
await skillManager.startSkill(autoLoadManifest);
|
||||
} catch (err) {
|
||||
console.error(`[SkillProvider] Failed to auto-load skill ${DEV_AUTO_LOAD_SKILL}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start skills with completed setup
|
||||
for (const manifest of manifests) {
|
||||
const existing = skillsState[manifest.id];
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { clearToken, setToken } from '../store/authSlice';
|
||||
import { clearToken, setAuthBootstrapComplete, setToken } from '../store/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { fetchTeams } from '../store/teamSlice';
|
||||
import { fetchCurrentUser } from '../store/userSlice';
|
||||
import { getSessionToken } from '../utils/tauriCommands';
|
||||
import { getAuthState, getSessionToken, isTauri } from '../utils/tauriCommands';
|
||||
|
||||
const AUTH_BOOTSTRAP_TIMEOUT_MS = 5000;
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UserProvider automatically fetches user data when JWT token is available.
|
||||
@@ -13,28 +28,43 @@ import { getSessionToken } from '../utils/tauriCommands';
|
||||
const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const token = useAppSelector(state => state.auth.token);
|
||||
const attemptedSessionRestoreRef = useRef(false);
|
||||
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
|
||||
|
||||
useEffect(() => {
|
||||
if (token || attemptedSessionRestoreRef.current) return;
|
||||
attemptedSessionRestoreRef.current = true;
|
||||
if (isAuthBootstrapComplete) return;
|
||||
|
||||
let mounted = true;
|
||||
void (async () => {
|
||||
if (!isTauri()) {
|
||||
if (mounted) dispatch(setAuthBootstrapComplete(true));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionToken = await getSessionToken();
|
||||
if (mounted && sessionToken) {
|
||||
dispatch(setToken(sessionToken));
|
||||
const [authState, sessionToken] = await withTimeout(
|
||||
Promise.all([getAuthState(), getSessionToken()]),
|
||||
AUTH_BOOTSTRAP_TIMEOUT_MS
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (authState.is_authenticated && sessionToken) {
|
||||
if (sessionToken !== token) {
|
||||
dispatch(setToken(sessionToken));
|
||||
}
|
||||
} else if (!authState.is_authenticated && token) {
|
||||
await dispatch(clearToken());
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[auth] Failed to restore session token from core RPC:', err);
|
||||
} finally {
|
||||
if (mounted) dispatch(setAuthBootstrapComplete(true));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [token, dispatch]);
|
||||
}, [token, dispatch, isAuthBootstrapComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { server } from '../../../test/server';
|
||||
// @ts-ignore - test-only JS module outside app/src
|
||||
import { setMockBehavior } from '../../../../../scripts/mock-api-core.mjs';
|
||||
|
||||
// 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 }));
|
||||
vi.mock('../../../services/backendUrl', () => ({
|
||||
getBackendUrl: vi.fn().mockResolvedValue('http://localhost:5005'),
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
const { userApi } = await import('../userApi');
|
||||
@@ -25,31 +26,22 @@ describe('userApi.getMe', () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
})
|
||||
);
|
||||
setMockBehavior('telegramMeStatus', '401');
|
||||
setMockBehavior('telegramMeError', 'Unauthorized');
|
||||
|
||||
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' });
|
||||
})
|
||||
);
|
||||
setMockBehavior('telegramMeStatus', '200');
|
||||
setMockBehavior('telegramMeError', '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();
|
||||
})
|
||||
);
|
||||
setMockBehavior('telegramMeStatus', '503');
|
||||
setMockBehavior('telegramMeError', 'Service unavailable');
|
||||
|
||||
await expect(userApi.getMe()).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
|
||||
|
||||
import { base64ToBytes, encryptIntegrationTokens } from '../../utils/integrationTokensCrypto';
|
||||
import { apiClient } from '../apiClient';
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
interface ConsumeLoginTokenResponse {
|
||||
success: boolean;
|
||||
data: { jwtToken: string };
|
||||
}
|
||||
|
||||
interface IntegrationTokensResponse {
|
||||
success: boolean;
|
||||
data?: { encrypted: string };
|
||||
@@ -41,28 +33,15 @@ function normalizeKeyToHex(rawKey: string): string {
|
||||
* POST /telegram/login-tokens/:token/consume (no auth required)
|
||||
*/
|
||||
export async function consumeLoginToken(loginToken: string): Promise<string> {
|
||||
if (coreIsTauri()) {
|
||||
const response = await callCoreRpc<{ result: { jwtToken: string } }>({
|
||||
method: 'openhuman.auth.consume_login_token',
|
||||
params: { loginToken },
|
||||
});
|
||||
const jwtToken = response.result?.jwtToken;
|
||||
if (!jwtToken) {
|
||||
throw new Error('Login token invalid or expired');
|
||||
}
|
||||
return jwtToken;
|
||||
}
|
||||
|
||||
const response = await apiClient.post<ConsumeLoginTokenResponse>(
|
||||
`/telegram/login-tokens/${encodeURIComponent(loginToken)}/consume`,
|
||||
undefined,
|
||||
{ requireAuth: false }
|
||||
);
|
||||
console.log('[ConsumeLoginToken] Response', response);
|
||||
if (!response.success || !response.data?.jwtToken) {
|
||||
const response = await callCoreRpc<{ result: { jwtToken: string } }>({
|
||||
method: 'openhuman.auth.consume_login_token',
|
||||
params: { loginToken },
|
||||
});
|
||||
const jwtToken = response.result?.jwtToken;
|
||||
if (!jwtToken) {
|
||||
throw new Error('Login token invalid or expired');
|
||||
}
|
||||
return response.data.jwtToken;
|
||||
return jwtToken;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,29 +52,22 @@ export async function fetchIntegrationTokens(
|
||||
integrationId: string,
|
||||
key: string
|
||||
): Promise<IntegrationTokensResponse> {
|
||||
if (coreIsTauri()) {
|
||||
const response = await callCoreRpc<{ result: IntegrationTokensPayload }>({
|
||||
method: 'openhuman.auth.oauth_fetch_integration_tokens',
|
||||
params: { integrationId, key },
|
||||
});
|
||||
const tokens = response.result;
|
||||
if (!tokens?.accessToken || !tokens?.expiresAt) {
|
||||
throw new Error('Integration token handoff did not return required fields');
|
||||
}
|
||||
|
||||
const encrypted = await encryptIntegrationTokens(
|
||||
JSON.stringify({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken ?? '',
|
||||
expiresAt: tokens.expiresAt,
|
||||
}),
|
||||
normalizeKeyToHex(key)
|
||||
);
|
||||
return { success: true, data: { encrypted } };
|
||||
const response = await callCoreRpc<{ result: IntegrationTokensPayload }>({
|
||||
method: 'openhuman.auth.oauth_fetch_integration_tokens',
|
||||
params: { integrationId, key },
|
||||
});
|
||||
const tokens = response.result;
|
||||
if (!tokens?.accessToken || !tokens?.expiresAt) {
|
||||
throw new Error('Integration token handoff did not return required fields');
|
||||
}
|
||||
|
||||
return apiClient.post<IntegrationTokensResponse>(
|
||||
`/auth/integrations/${encodeURIComponent(integrationId)}/tokens`,
|
||||
{ key }
|
||||
const encrypted = await encryptIntegrationTokens(
|
||||
JSON.stringify({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken ?? '',
|
||||
expiresAt: tokens.expiresAt,
|
||||
}),
|
||||
normalizeKeyToHex(key)
|
||||
);
|
||||
return { success: true, data: { encrypted } };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
|
||||
|
||||
import type { ApiResponse } from '../../types/api';
|
||||
import type {
|
||||
ChannelAuthMode,
|
||||
ChannelConnection,
|
||||
ChannelConnectionsByMode,
|
||||
ChannelType,
|
||||
} from '../../types/channels';
|
||||
import { apiClient } from '../apiClient';
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
interface ConnectChannelPayload {
|
||||
@@ -63,133 +59,106 @@ function makeConnectedChannelConnection(
|
||||
|
||||
export const channelConnectionsApi = {
|
||||
listConnections: async (): Promise<ChannelConnectionsResponse> => {
|
||||
if (coreIsTauri()) {
|
||||
const [profilesResponse, integrationsResponse] = await Promise.all([
|
||||
callCoreRpc<{ result: AuthProfileSummary[] }>({
|
||||
method: 'openhuman.auth.list_provider_credentials',
|
||||
params: {},
|
||||
}),
|
||||
callCoreRpc<{ result: OAuthIntegrationSummary[] }>({
|
||||
method: 'openhuman.auth.oauth_list_integrations',
|
||||
params: {},
|
||||
}),
|
||||
]);
|
||||
const [profilesResponse, integrationsResponse] = await Promise.all([
|
||||
callCoreRpc<{ result: AuthProfileSummary[] }>({
|
||||
method: 'openhuman.auth.list_provider_credentials',
|
||||
params: {},
|
||||
}),
|
||||
callCoreRpc<{ result: OAuthIntegrationSummary[] }>({
|
||||
method: 'openhuman.auth.oauth_list_integrations',
|
||||
params: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
const output = emptyChannelConnectionsResponse();
|
||||
const profiles = profilesResponse.result ?? [];
|
||||
const integrations = integrationsResponse.result ?? [];
|
||||
const output = emptyChannelConnectionsResponse();
|
||||
const profiles = profilesResponse.result ?? [];
|
||||
const integrations = integrationsResponse.result ?? [];
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (!isSupportedChannel(profile.provider)) continue;
|
||||
const authMode = profile.profileName as ChannelAuthMode;
|
||||
if (!SUPPORTED_AUTH_MODES.includes(authMode) || authMode === 'oauth') continue;
|
||||
output.connections[profile.provider][authMode] = makeConnectedChannelConnection(
|
||||
profile.provider,
|
||||
authMode
|
||||
);
|
||||
}
|
||||
|
||||
for (const integration of integrations) {
|
||||
if (!isSupportedChannel(integration.provider)) continue;
|
||||
output.connections[integration.provider].oauth = makeConnectedChannelConnection(
|
||||
integration.provider,
|
||||
'oauth'
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
for (const profile of profiles) {
|
||||
if (!isSupportedChannel(profile.provider)) continue;
|
||||
const authMode = profile.profileName as ChannelAuthMode;
|
||||
if (!SUPPORTED_AUTH_MODES.includes(authMode) || authMode === 'oauth') continue;
|
||||
output.connections[profile.provider][authMode] = makeConnectedChannelConnection(
|
||||
profile.provider,
|
||||
authMode
|
||||
);
|
||||
}
|
||||
|
||||
const response =
|
||||
await apiClient.get<ApiResponse<ChannelConnectionsResponse>>('/channels/connections');
|
||||
return response.data;
|
||||
for (const integration of integrations) {
|
||||
if (!isSupportedChannel(integration.provider)) continue;
|
||||
output.connections[integration.provider].oauth = makeConnectedChannelConnection(
|
||||
integration.provider,
|
||||
'oauth'
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
},
|
||||
|
||||
connectChannel: async (
|
||||
channel: ChannelType,
|
||||
payload: ConnectChannelPayload
|
||||
): Promise<ConnectChannelResponse> => {
|
||||
if (coreIsTauri()) {
|
||||
if (payload.authMode === 'oauth') {
|
||||
const response = await callCoreRpc<{ result: { oauthUrl: string } }>({
|
||||
method: 'openhuman.auth.oauth_connect',
|
||||
params: { provider: channel, skillId: channel },
|
||||
});
|
||||
return {
|
||||
oauthUrl: response.result.oauthUrl,
|
||||
connection: makeConnectedChannelConnection(channel, payload.authMode),
|
||||
};
|
||||
}
|
||||
|
||||
const token =
|
||||
payload.authMode === 'bot_token'
|
||||
? payload.credentials?.botToken?.trim()
|
||||
: payload.authMode === 'api_key'
|
||||
? payload.credentials?.apiKey?.trim()
|
||||
: undefined;
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.auth.store_provider_credentials',
|
||||
params: {
|
||||
provider: channel,
|
||||
profile: payload.authMode,
|
||||
token,
|
||||
fields: { authMode: payload.authMode },
|
||||
setActive: true,
|
||||
},
|
||||
if (payload.authMode === 'oauth') {
|
||||
const response = await callCoreRpc<{ result: { oauthUrl: string } }>({
|
||||
method: 'openhuman.auth.oauth_connect',
|
||||
params: { provider: channel, skillId: channel },
|
||||
});
|
||||
|
||||
return { connection: makeConnectedChannelConnection(channel, payload.authMode) };
|
||||
return {
|
||||
oauthUrl: response.result.oauthUrl,
|
||||
connection: makeConnectedChannelConnection(channel, payload.authMode),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiClient.post<ApiResponse<ConnectChannelResponse>>(
|
||||
`/channels/${encodeURIComponent(channel)}/connect`,
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
const token =
|
||||
payload.authMode === 'bot_token'
|
||||
? payload.credentials?.botToken?.trim()
|
||||
: payload.authMode === 'api_key'
|
||||
? payload.credentials?.apiKey?.trim()
|
||||
: undefined;
|
||||
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.auth.store_provider_credentials',
|
||||
params: {
|
||||
provider: channel,
|
||||
profile: payload.authMode,
|
||||
token,
|
||||
fields: { authMode: payload.authMode },
|
||||
setActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { connection: makeConnectedChannelConnection(channel, payload.authMode) };
|
||||
},
|
||||
|
||||
disconnectChannel: async (channel: ChannelType, authMode: ChannelAuthMode): Promise<void> => {
|
||||
if (coreIsTauri()) {
|
||||
if (authMode === 'oauth') {
|
||||
const listResponse = await callCoreRpc<{ result: OAuthIntegrationSummary[] }>({
|
||||
method: 'openhuman.auth.oauth_list_integrations',
|
||||
params: {},
|
||||
});
|
||||
const integrationIds = (listResponse.result ?? [])
|
||||
.filter(item => item.provider === channel)
|
||||
.map(item => item.id);
|
||||
await Promise.all(
|
||||
integrationIds.map(integrationId =>
|
||||
callCoreRpc({
|
||||
method: 'openhuman.auth.oauth_revoke_integration',
|
||||
params: { integrationId },
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.auth.remove_provider_credentials',
|
||||
params: { provider: channel, profile: authMode },
|
||||
if (authMode === 'oauth') {
|
||||
const listResponse = await callCoreRpc<{ result: OAuthIntegrationSummary[] }>({
|
||||
method: 'openhuman.auth.oauth_list_integrations',
|
||||
params: {},
|
||||
});
|
||||
const integrationIds = (listResponse.result ?? [])
|
||||
.filter(item => item.provider === channel)
|
||||
.map(item => item.id);
|
||||
|
||||
await Promise.all(
|
||||
integrationIds.map(integrationId =>
|
||||
callCoreRpc({
|
||||
method: 'openhuman.auth.oauth_revoke_integration',
|
||||
params: { integrationId },
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.post<ApiResponse<unknown>>(
|
||||
`/channels/${encodeURIComponent(channel)}/disconnect`,
|
||||
{ authMode }
|
||||
);
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.auth.remove_provider_credentials',
|
||||
params: { provider: channel, profile: authMode },
|
||||
});
|
||||
},
|
||||
|
||||
updatePreferences: async (defaultMessagingChannel: ChannelType): Promise<void> => {
|
||||
if (coreIsTauri()) {
|
||||
void defaultMessagingChannel;
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.patch<ApiResponse<unknown>>('/channels/preferences', {
|
||||
defaultMessagingChannel,
|
||||
});
|
||||
void defaultMessagingChannel;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ApiError } from '../types/api';
|
||||
import { BACKEND_URL } from '../utils/config';
|
||||
import { getBackendUrl } from './backendUrl';
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
@@ -29,12 +29,6 @@ export function setStoreForApiClient(getToken: () => string | null) {
|
||||
* Handles authentication, error handling, and response typing
|
||||
*/
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private getToken(): string | null {
|
||||
return _getToken ? _getToken() : null;
|
||||
}
|
||||
@@ -65,7 +59,8 @@ class ApiClient {
|
||||
private async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, requireAuth = true, timeout = 120_000 } = options;
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const baseUrl = await getBackendUrl();
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
const headers = this.buildHeaders({ ...options, requireAuth });
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -170,6 +165,4 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const apiClient = new ApiClient(BACKEND_URL);
|
||||
|
||||
console.log('[ApiClient] Backend URL', BACKEND_URL);
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
|
||||
|
||||
import { callCoreRpc } from './coreRpcClient';
|
||||
|
||||
let resolvedBackendUrl: string | null = null;
|
||||
let resolvingBackendUrl: Promise<string> | null = null;
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
return url.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function webFallbackBackendUrl(): string {
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return normalizeBaseUrl(window.location.origin);
|
||||
}
|
||||
return 'http://127.0.0.1:3000';
|
||||
}
|
||||
|
||||
export async function getBackendUrl(): Promise<string> {
|
||||
if (resolvedBackendUrl) {
|
||||
return resolvedBackendUrl;
|
||||
}
|
||||
|
||||
if (!coreIsTauri()) {
|
||||
resolvedBackendUrl = webFallbackBackendUrl();
|
||||
return resolvedBackendUrl;
|
||||
}
|
||||
|
||||
if (resolvingBackendUrl) {
|
||||
return resolvingBackendUrl;
|
||||
}
|
||||
|
||||
resolvingBackendUrl = (async () => {
|
||||
const response = await callCoreRpc<{ api_url?: string; apiUrl?: string }>({
|
||||
method: 'openhuman.config_resolve_api_url',
|
||||
});
|
||||
const resolved = String(response.api_url ?? response.apiUrl ?? '').trim();
|
||||
if (!resolved) {
|
||||
throw new Error('Core returned an empty backend URL');
|
||||
}
|
||||
resolvedBackendUrl = normalizeBaseUrl(resolved);
|
||||
return resolvedBackendUrl;
|
||||
})().finally(() => {
|
||||
resolvingBackendUrl = null;
|
||||
});
|
||||
|
||||
return resolvingBackendUrl;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory';
|
||||
import { CORE_RPC_URL } from '../utils/config';
|
||||
|
||||
interface CoreRpcRelayRequest {
|
||||
method: string;
|
||||
@@ -8,6 +9,44 @@ interface CoreRpcRelayRequest {
|
||||
serviceManaged?: boolean;
|
||||
}
|
||||
|
||||
interface JsonRpcRequestBody {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
method: string;
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse<T> {
|
||||
jsonrpc?: string;
|
||||
id?: number | string | null;
|
||||
result?: T;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
const LEGACY_METHOD_ALIASES: Record<string, string> = {
|
||||
'openhuman.get_config': 'openhuman.config_get',
|
||||
'openhuman.get_runtime_flags': 'openhuman.config_get_runtime_flags',
|
||||
'openhuman.set_browser_allow_all': 'openhuman.config_set_browser_allow_all',
|
||||
'openhuman.update_browser_settings': 'openhuman.config_update_browser_settings',
|
||||
'openhuman.update_memory_settings': 'openhuman.config_update_memory_settings',
|
||||
'openhuman.update_model_settings': 'openhuman.config_update_model_settings',
|
||||
'openhuman.update_runtime_settings': 'openhuman.config_update_runtime_settings',
|
||||
'openhuman.update_screen_intelligence_settings':
|
||||
'openhuman.config_update_screen_intelligence_settings',
|
||||
'openhuman.update_tunnel_settings': 'openhuman.config_update_tunnel_settings',
|
||||
'openhuman.workspace_onboarding_flag_exists': 'openhuman.config_workspace_onboarding_flag_exists',
|
||||
};
|
||||
|
||||
let nextJsonRpcId = 1;
|
||||
let resolvedCoreRpcUrl: string | null = null;
|
||||
let resolvingCoreRpcUrl: Promise<string> | null = null;
|
||||
|
||||
function coreRpcErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error && err.message) {
|
||||
return err.message;
|
||||
@@ -28,18 +67,95 @@ function coreRpcErrorMessage(err: unknown): string {
|
||||
return 'Unknown core RPC error';
|
||||
}
|
||||
|
||||
function normalizeLegacyMethod(method: string): string {
|
||||
if (method in LEGACY_METHOD_ALIASES) {
|
||||
return LEGACY_METHOD_ALIASES[method];
|
||||
}
|
||||
|
||||
if (method.startsWith('openhuman.auth.')) {
|
||||
return `openhuman.auth_${method.slice('openhuman.auth.'.length).split('.').join('_')}`;
|
||||
}
|
||||
|
||||
if (method.startsWith('openhuman.accessibility_')) {
|
||||
return method.replace('openhuman.accessibility_', 'openhuman.screen_intelligence_');
|
||||
}
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
async function getCoreRpcUrl(): Promise<string> {
|
||||
if (resolvedCoreRpcUrl) {
|
||||
return resolvedCoreRpcUrl;
|
||||
}
|
||||
|
||||
if (!coreIsTauri()) {
|
||||
resolvedCoreRpcUrl = CORE_RPC_URL;
|
||||
return CORE_RPC_URL;
|
||||
}
|
||||
|
||||
if (resolvingCoreRpcUrl) {
|
||||
return resolvingCoreRpcUrl;
|
||||
}
|
||||
|
||||
const resolvePromise: Promise<string> = (async () => {
|
||||
try {
|
||||
const url = await invoke<string>('core_rpc_url');
|
||||
const trimmed = String(url || '').trim();
|
||||
resolvedCoreRpcUrl = trimmed || CORE_RPC_URL;
|
||||
return resolvedCoreRpcUrl || CORE_RPC_URL;
|
||||
} catch {
|
||||
resolvedCoreRpcUrl = CORE_RPC_URL;
|
||||
return CORE_RPC_URL;
|
||||
} finally {
|
||||
resolvingCoreRpcUrl = null;
|
||||
}
|
||||
})();
|
||||
resolvingCoreRpcUrl = resolvePromise;
|
||||
|
||||
return resolvePromise;
|
||||
}
|
||||
|
||||
export async function callCoreRpc<T>({
|
||||
method,
|
||||
params,
|
||||
serviceManaged = false,
|
||||
serviceManaged = false, // kept for compatibility; direct frontend RPC does not use relay-level routing.
|
||||
}: CoreRpcRelayRequest): Promise<T> {
|
||||
void serviceManaged;
|
||||
|
||||
if (method.startsWith('ai.')) {
|
||||
return dispatchLocalAiMethod(method, (params ?? {}) as Record<string, unknown>) as T;
|
||||
}
|
||||
|
||||
const normalizedMethod = normalizeLegacyMethod(method);
|
||||
const payload: JsonRpcRequestBody = {
|
||||
jsonrpc: '2.0',
|
||||
id: nextJsonRpcId++,
|
||||
method: normalizedMethod,
|
||||
params: params ?? {},
|
||||
};
|
||||
|
||||
try {
|
||||
return await invoke<T>('core_rpc_relay', {
|
||||
request: { method, params: params ?? {}, serviceManaged },
|
||||
const rpcUrl = await getCoreRpcUrl();
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Core RPC HTTP ${response.status}: ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as JsonRpcResponse<T>;
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message || 'Core RPC returned an error');
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(json, 'result')) {
|
||||
throw new Error('Core RPC response missing result');
|
||||
}
|
||||
|
||||
return json.result as T;
|
||||
} catch (err) {
|
||||
throw new Error(coreRpcErrorMessage(err));
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { io, Socket } from 'socket.io-client';
|
||||
|
||||
import { MCPTool, MCPToolCall, SocketIOMCPTransportImpl } from '../lib/mcp';
|
||||
import { skillManager, syncToolsToBackend } from '../lib/skills';
|
||||
import { getBackendUrl } from '../services/backendUrl';
|
||||
import { store } from '../store';
|
||||
import { upsertChannelConnection } from '../store/channelConnectionsSlice';
|
||||
import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice';
|
||||
import type { ChannelAuthMode, ChannelConnectionStatus, ChannelType } from '../types/channels';
|
||||
import { BACKEND_URL, IS_DEV } from '../utils/config';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { createSafeLogData, sanitizeError } from '../utils/sanitize';
|
||||
|
||||
// Socket service logger using debug package
|
||||
@@ -99,6 +100,10 @@ class SocketService {
|
||||
* handles the connection. The frontend calls `connectRustSocket()` instead.
|
||||
*/
|
||||
connect(token: string): void {
|
||||
void this.connectAsync(token);
|
||||
}
|
||||
|
||||
private async connectAsync(token: string): Promise<void> {
|
||||
if (!token) return;
|
||||
|
||||
// In Tauri mode, Rust handles the socket connection.
|
||||
@@ -126,12 +131,10 @@ class SocketService {
|
||||
|
||||
this.token = token;
|
||||
const uid = getSocketUserId();
|
||||
|
||||
socketLog('Connecting', { userId: uid, backendUrl: BACKEND_URL });
|
||||
|
||||
store.dispatch(setStatusForUser({ userId: uid, status: 'connecting' }));
|
||||
|
||||
const backendUrl = BACKEND_URL;
|
||||
const backendUrl = await getBackendUrl();
|
||||
socketLog('Connecting', { userId: uid, backendUrl });
|
||||
|
||||
// Ensure we're not connecting to the wrong URL
|
||||
if (backendUrl.includes('localhost:1420') || backendUrl.includes(':1420')) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { clearUser } from './userSlice';
|
||||
|
||||
export interface AuthState {
|
||||
token: string | null;
|
||||
/** True once startup auth/session bootstrap has checked core binary state */
|
||||
isAuthBootstrapComplete: boolean;
|
||||
/** Onboarding completion per user id */
|
||||
isOnboardedByUser: Record<string, boolean>;
|
||||
/** Additional onboarding task progress per user id */
|
||||
@@ -28,6 +30,7 @@ export interface UserOnboardingTasks {
|
||||
|
||||
const initialState: AuthState = {
|
||||
token: null,
|
||||
isAuthBootstrapComplete: false,
|
||||
isOnboardedByUser: {},
|
||||
onboardingTasksByUser: {},
|
||||
hasIncompleteOnboardingByUser: {},
|
||||
@@ -43,6 +46,9 @@ const authSlice = createSlice({
|
||||
setToken: (state, action: PayloadAction<string>) => {
|
||||
state.token = action.payload;
|
||||
},
|
||||
setAuthBootstrapComplete: (state, action: PayloadAction<boolean>) => {
|
||||
state.isAuthBootstrapComplete = action.payload;
|
||||
},
|
||||
_clearToken: state => {
|
||||
state.token = null;
|
||||
state.onboardingTasksByUser = {};
|
||||
@@ -92,6 +98,7 @@ export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispat
|
||||
|
||||
export const {
|
||||
setToken,
|
||||
setAuthBootstrapComplete,
|
||||
setOnboardedForUser,
|
||||
setAnalyticsForUser,
|
||||
setOnboardingTasksForUser,
|
||||
|
||||
+43
-24
@@ -14,7 +14,11 @@ import storage from 'redux-persist/lib/storage';
|
||||
|
||||
import { setStoreForApiClient } from '../services/apiClient';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { storeSession, syncMemoryClientToken } from '../utils/tauriCommands';
|
||||
import {
|
||||
logout as clearRustSession,
|
||||
storeSession,
|
||||
syncMemoryClientToken,
|
||||
} from '../utils/tauriCommands';
|
||||
import accessibilityReducer from './accessibilitySlice';
|
||||
import aiReducer from './aiSlice';
|
||||
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
|
||||
@@ -77,34 +81,49 @@ const persistedChannelConnectionsReducer = persistReducer(
|
||||
* Middleware that syncs the JWT token to the Rust SESSION_SERVICE whenever
|
||||
* setToken is dispatched or auth state is rehydrated from persist.
|
||||
*/
|
||||
const syncTokenToRust: Middleware = () => next => action => {
|
||||
const result = next(action);
|
||||
const syncTokenToRust: Middleware = () => {
|
||||
let lastSyncedToken: string | null = null;
|
||||
return next => action => {
|
||||
const result = next(action);
|
||||
|
||||
const syncToken = (token: string) => {
|
||||
// Pass a minimal user object — the token is what matters for SESSION_SERVICE
|
||||
storeSession(token, { id: '' }).catch(err =>
|
||||
console.warn('[syncTokenToRust] Failed to sync token:', err)
|
||||
);
|
||||
syncMemoryClientToken(token).catch(err =>
|
||||
console.warn('[syncTokenToRust] Failed to sync memory token:', err)
|
||||
);
|
||||
};
|
||||
const syncToken = (token: string) => {
|
||||
if (token === lastSyncedToken) return;
|
||||
lastSyncedToken = token;
|
||||
|
||||
// Sync on explicit setToken
|
||||
if (setToken.match(action) && action.payload) {
|
||||
syncToken(action.payload);
|
||||
}
|
||||
// Pass a minimal user object — the token is what matters for SESSION_SERVICE
|
||||
storeSession(token, { id: '' }).catch(err =>
|
||||
console.warn('[syncTokenToRust] Failed to sync token:', err)
|
||||
);
|
||||
syncMemoryClientToken(token).catch(err =>
|
||||
console.warn('[syncTokenToRust] Failed to sync memory token:', err)
|
||||
);
|
||||
};
|
||||
|
||||
// Sync on rehydration (app restart — persist loads token from localStorage)
|
||||
const a = action as { type?: string; key?: string; payload?: { token?: string } };
|
||||
if (a.type === REHYDRATE && a.key === 'auth') {
|
||||
const token = a.payload?.token;
|
||||
if (token) {
|
||||
syncToken(token);
|
||||
// Sync on explicit setToken
|
||||
if (setToken.match(action) && action.payload) {
|
||||
syncToken(action.payload);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
if ((action as { type?: string }).type === 'auth/_clearToken') {
|
||||
lastSyncedToken = null;
|
||||
clearRustSession().catch(err =>
|
||||
console.warn('[syncTokenToRust] Failed to clear core session:', err)
|
||||
);
|
||||
}
|
||||
|
||||
// Sync on rehydration (app restart — persist loads token from localStorage)
|
||||
const a = action as { type?: string; key?: string; payload?: { token?: string } };
|
||||
if (a.type === REHYDRATE && a.key === 'auth') {
|
||||
const token = a.payload?.token;
|
||||
if (token) {
|
||||
syncToken(token);
|
||||
} else {
|
||||
lastSyncedToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
export const store = configureStore({
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* 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' },
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* MSW server instance for use in Vitest tests.
|
||||
*/
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
+33
-10
@@ -2,7 +2,7 @@
|
||||
* Global test setup for Vitest.
|
||||
*
|
||||
* - Extends expect with @testing-library/jest-dom matchers
|
||||
* - Sets up MSW server for API mocking
|
||||
* - Starts local HTTP mock backend 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
|
||||
@@ -12,10 +12,15 @@ import { cleanup } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest';
|
||||
|
||||
import { server } from './server';
|
||||
// @ts-ignore - test-only JS module outside app/src
|
||||
import {
|
||||
clearRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../../../scripts/mock-api-core.mjs';
|
||||
|
||||
// Mock import.meta.env defaults for tests
|
||||
vi.stubEnv('VITE_BACKEND_URL', 'http://localhost:5005');
|
||||
vi.stubEnv('DEV', true);
|
||||
vi.stubEnv('MODE', 'test');
|
||||
|
||||
@@ -32,21 +37,34 @@ vi.mock('@tauri-apps/plugin-os', () => ({ platform: vi.fn().mockResolvedValue('m
|
||||
|
||||
// Mock tauriCommands to prevent Tauri API calls in tests
|
||||
vi.mock('../utils/tauriCommands', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: vi.fn(() => false),
|
||||
storeSession: vi.fn().mockResolvedValue(undefined),
|
||||
getSessionToken: vi.fn().mockResolvedValue(null),
|
||||
getAuthState: vi.fn().mockResolvedValue({ is_authenticated: false }),
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
syncMemoryClientToken: vi.fn().mockResolvedValue(undefined),
|
||||
openhumanServiceInstall: vi.fn().mockResolvedValue({ result: { state: 'Running' }, logs: [] }),
|
||||
openhumanServiceStart: vi.fn().mockResolvedValue({ result: { state: 'Running' }, logs: [] }),
|
||||
openhumanServiceStop: vi.fn().mockResolvedValue({ result: { state: 'Stopped' }, logs: [] }),
|
||||
openhumanServiceStatus: vi.fn().mockResolvedValue({ result: { state: 'Running' }, logs: [] }),
|
||||
openhumanServiceUninstall: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] }),
|
||||
openhumanAgentServerStatus: vi.fn().mockResolvedValue({ result: { running: true }, logs: [] }),
|
||||
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,
|
||||
}));
|
||||
|
||||
vi.mock('../services/backendUrl', () => ({
|
||||
getBackendUrl: vi.fn().mockResolvedValue('http://localhost:5005'),
|
||||
}));
|
||||
|
||||
// Mock redux-persist to avoid CJS/ESM issues in vitest
|
||||
@@ -109,16 +127,21 @@ if (!process.env.DEBUG_TESTS) {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
}
|
||||
|
||||
// MSW server lifecycle
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
// Shared mock API server lifecycle for unit tests (default)
|
||||
beforeAll(async () => {
|
||||
await startMockServer(5005);
|
||||
});
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
clearRequestLog();
|
||||
cleanup();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
afterAll(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
// Reset rate limiter per-request counter before each test.
|
||||
beforeEach(async () => {
|
||||
resetMockBehavior();
|
||||
try {
|
||||
const { resetRequestCallCount } = await import('../lib/mcp/rateLimiter');
|
||||
if (typeof resetRequestCallCount === 'function') {
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module '*.mjs';
|
||||
@@ -11,7 +11,6 @@ export interface OAuthProviderConfig {
|
||||
color: string;
|
||||
hoverColor: string;
|
||||
textColor: string;
|
||||
loginUrl: string;
|
||||
}
|
||||
|
||||
export interface OAuthLoginResponse {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { isTauri as tauriRuntimeIsTauri } from '@tauri-apps/api/core';
|
||||
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
|
||||
import * as tauriCommands from '../tauriCommands';
|
||||
// @ts-ignore - test-only JS module outside app/src
|
||||
import { clearRequestLog, getRequestLog } from '../../../../scripts/mock-api-core.mjs';
|
||||
import PublicRoute from '../../components/PublicRoute';
|
||||
import UserProvider from '../../providers/UserProvider';
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
import { store } from '../../store';
|
||||
import { clearToken, setAuthBootstrapComplete } from '../../store/authSlice';
|
||||
import { setupDesktopDeepLinkListener } from '../desktopDeepLinkListener';
|
||||
|
||||
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
describe('Auth flow e2e (binary + OAuth callback)', () => {
|
||||
const mockIsTauriRuntime = tauriRuntimeIsTauri as Mock;
|
||||
const mockGetCurrent = getCurrent as Mock;
|
||||
const mockOnOpenUrl = onOpenUrl as Mock;
|
||||
const mockCallCoreRpc = callCoreRpc as Mock;
|
||||
|
||||
const mockIsTauriCommand = tauriCommands.isTauri as Mock;
|
||||
const mockGetAuthState = tauriCommands.getAuthState as Mock;
|
||||
const mockGetSessionToken = tauriCommands.getSessionToken as Mock;
|
||||
const mockStoreSession = tauriCommands.storeSession as Mock;
|
||||
const mockSyncMemoryClientToken = tauriCommands.syncMemoryClientToken as Mock;
|
||||
const mockLogout = tauriCommands.logout as Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
clearRequestLog();
|
||||
await store.dispatch(clearToken());
|
||||
store.dispatch(setAuthBootstrapComplete(false));
|
||||
window.location.hash = '/';
|
||||
|
||||
mockIsTauriRuntime.mockReturnValue(false);
|
||||
mockGetCurrent.mockResolvedValue(null);
|
||||
mockOnOpenUrl.mockResolvedValue(() => {});
|
||||
|
||||
mockIsTauriCommand.mockReturnValue(false);
|
||||
mockGetAuthState.mockResolvedValue({ is_authenticated: false, user: null });
|
||||
mockGetSessionToken.mockResolvedValue(null);
|
||||
mockStoreSession.mockResolvedValue(undefined);
|
||||
mockSyncMemoryClientToken.mockResolvedValue(undefined);
|
||||
mockLogout.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('bootstraps token from core auth state and routes public entry to /home', async () => {
|
||||
mockIsTauriCommand.mockReturnValue(true);
|
||||
mockGetAuthState.mockResolvedValue({ is_authenticated: true, user: { _id: 'user-123' } });
|
||||
mockGetSessionToken.mockResolvedValue('jwt-from-core');
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<UserProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<div>Welcome</div>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/home" element={<div>Home</div>} />
|
||||
</Routes>
|
||||
</UserProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Home')).toBeInTheDocument());
|
||||
expect(store.getState().auth.token).toBe('jwt-from-core');
|
||||
expect(mockGetAuthState).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetSessionToken).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(mockStoreSession).toHaveBeenCalledWith('jwt-from-core', { id: '' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const requests = getRequestLog() as Array<{ method: string; url: string }>;
|
||||
expect(requests.some(req => req.method === 'GET' && req.url.startsWith('/telegram/me'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('consumes OAuth login token from deep link and updates auth token + redirect', async () => {
|
||||
mockIsTauriRuntime.mockReturnValue(true);
|
||||
mockCallCoreRpc.mockResolvedValue({ result: { jwtToken: 'jwt-from-login-token' }, logs: [] });
|
||||
|
||||
await setupDesktopDeepLinkListener();
|
||||
expect(mockOnOpenUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
const openUrlHandler = mockOnOpenUrl.mock.calls[0][0] as (urls: string[]) => void;
|
||||
openUrlHandler(['openhuman://auth?token=oauth-login-token-123']);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.auth.consume_login_token',
|
||||
params: { loginToken: 'oauth-login-token-123' },
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(store.getState().auth.token).toBe('jwt-from-login-token'));
|
||||
expect(window.location.hash).toBe('#/home');
|
||||
await waitFor(() =>
|
||||
expect(mockStoreSession).toHaveBeenCalledWith('jwt-from-login-token', { id: '' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import { isTauri } from '@tauri-apps/api/core';
|
||||
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
|
||||
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
import type { ServiceState } from '../tauriCommands';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
|
||||
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
describe('Web → Tauri → Core bridge', () => {
|
||||
const mockInvoke = invoke as Mock;
|
||||
describe('Web → frontend JSON-RPC → Core bridge', () => {
|
||||
const mockIsTauri = isTauri as Mock;
|
||||
const mockCallCoreRpc = callCoreRpc as Mock;
|
||||
let openhumanServiceStatus: typeof import('../tauriCommands').openhumanServiceStatus;
|
||||
let openhumanAgentServerStatus: typeof import('../tauriCommands').openhumanAgentServerStatus;
|
||||
|
||||
@@ -19,34 +21,30 @@ describe('Web → Tauri → Core bridge', () => {
|
||||
openhumanAgentServerStatus = actual.openhumanAgentServerStatus;
|
||||
});
|
||||
|
||||
test('routes service status request through Tauri command and returns core payload', async () => {
|
||||
test('routes service status via JSON-RPC client and returns core payload', async () => {
|
||||
const expectedState: ServiceState = 'Running';
|
||||
const tauriResponse = { result: { state: expectedState }, logs: ['service status fetched'] };
|
||||
const rpcResponse = { result: { state: expectedState }, logs: ['service status fetched'] };
|
||||
|
||||
mockInvoke.mockResolvedValueOnce(tauriResponse);
|
||||
mockCallCoreRpc.mockResolvedValueOnce(rpcResponse);
|
||||
|
||||
const response = await openhumanServiceStatus();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('core_rpc_relay', {
|
||||
request: { method: 'openhuman.service_status', params: {}, serviceManaged: false },
|
||||
});
|
||||
expect(response).toEqual(tauriResponse);
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.service_status' });
|
||||
expect(response).toEqual(rpcResponse);
|
||||
expect(response.result.state).toBe(expectedState);
|
||||
});
|
||||
|
||||
test('routes agent server status through Tauri command and returns core_rpc status', async () => {
|
||||
const tauriResponse = {
|
||||
test('routes agent server status via JSON-RPC client', async () => {
|
||||
const rpcResponse = {
|
||||
result: { running: true, url: 'http://127.0.0.1:8421/rpc' },
|
||||
logs: ['agent server status checked'],
|
||||
};
|
||||
|
||||
mockInvoke.mockResolvedValueOnce(tauriResponse);
|
||||
mockCallCoreRpc.mockResolvedValueOnce(rpcResponse);
|
||||
|
||||
const response = await openhumanAgentServerStatus();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('core_rpc_relay', {
|
||||
request: { method: 'openhuman.agent_server_status', params: {}, serviceManaged: false },
|
||||
});
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.agent_server_status' });
|
||||
expect(response.result.running).toBe(true);
|
||||
expect(response.result.url).toContain('127.0.0.1');
|
||||
});
|
||||
@@ -55,6 +53,6 @@ describe('Web → Tauri → Core bridge', () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
await expect(openhumanServiceStatus()).rejects.toThrow('Not running in Tauri');
|
||||
expect(mockInvoke).not.toHaveBeenCalled();
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.tinyhumans.ai';
|
||||
export const CORE_RPC_URL =
|
||||
import.meta.env.OPENHUMAN_CORE_RPC_URL ||
|
||||
import.meta.env.VITE_OPENHUMAN_CORE_RPC_URL ||
|
||||
'http://127.0.0.1:7788/rpc';
|
||||
|
||||
export const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
export const SKILLS_GITHUB_REPO =
|
||||
import.meta.env.VITE_SKILLS_GITHUB_REPO || 'tinyhumansai/openhuman-skills';
|
||||
|
||||
export const DEV_AUTO_LOAD_SKILL = import.meta.env.VITE_DEV_AUTO_LOAD_SKILL || undefined;
|
||||
|
||||
@@ -56,11 +56,11 @@ const handleAuthDeepLink = async (parsed: URL) => {
|
||||
|
||||
if (key === 'auth') {
|
||||
store.dispatch(setToken(token));
|
||||
window.location.hash = '/onboarding';
|
||||
window.location.hash = '/home';
|
||||
} else {
|
||||
const jwtToken = await consumeLoginToken(token);
|
||||
store.dispatch(setToken(jwtToken));
|
||||
window.location.hash = '/onboarding';
|
||||
window.location.hash = '/home';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1107,9 +1107,6 @@ export async function openhumanAgentChat(
|
||||
}
|
||||
|
||||
export async function openhumanLocalAiStatus(): Promise<CommandResponse<LocalAiStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
try {
|
||||
return await callCoreRpc<CommandResponse<LocalAiStatus>>({
|
||||
method: 'openhuman.local_ai_status',
|
||||
@@ -1128,9 +1125,6 @@ export async function openhumanLocalAiStatus(): Promise<CommandResponse<LocalAiS
|
||||
export async function openhumanLocalAiDownload(
|
||||
force?: boolean
|
||||
): Promise<CommandResponse<LocalAiStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
try {
|
||||
return await callCoreRpc<CommandResponse<LocalAiStatus>>({
|
||||
method: 'openhuman.local_ai_download',
|
||||
@@ -1148,9 +1142,6 @@ export async function openhumanLocalAiDownload(
|
||||
export async function openhumanLocalAiDownloadAllAssets(
|
||||
force?: boolean
|
||||
): Promise<CommandResponse<LocalAiDownloadsProgress>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<LocalAiDownloadsProgress>>({
|
||||
method: 'openhuman.local_ai_download_all_assets',
|
||||
params: { force: force ?? false },
|
||||
@@ -1161,9 +1152,6 @@ export async function openhumanLocalAiSummarize(
|
||||
text: string,
|
||||
maxTokens?: number
|
||||
): Promise<CommandResponse<string>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<string>>({
|
||||
method: 'openhuman.local_ai_summarize',
|
||||
params: { text, max_tokens: maxTokens },
|
||||
@@ -1174,9 +1162,6 @@ export async function openhumanLocalAiSuggestQuestions(
|
||||
context?: string,
|
||||
lines?: string[]
|
||||
): Promise<CommandResponse<LocalAiSuggestion[]>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<LocalAiSuggestion[]>>({
|
||||
method: 'openhuman.local_ai_suggest_questions',
|
||||
params: { context, lines },
|
||||
@@ -1188,9 +1173,6 @@ export async function openhumanLocalAiPrompt(
|
||||
maxTokens?: number,
|
||||
noThink?: boolean
|
||||
): Promise<CommandResponse<string>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<string>>({
|
||||
method: 'openhuman.local_ai_prompt',
|
||||
params: { prompt, max_tokens: maxTokens, no_think: noThink },
|
||||
@@ -1202,9 +1184,6 @@ export async function openhumanLocalAiVisionPrompt(
|
||||
imageRefs: string[],
|
||||
maxTokens?: number
|
||||
): Promise<CommandResponse<string>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<string>>({
|
||||
method: 'openhuman.local_ai_vision_prompt',
|
||||
params: { prompt, image_refs: imageRefs, max_tokens: maxTokens },
|
||||
@@ -1214,9 +1193,6 @@ export async function openhumanLocalAiVisionPrompt(
|
||||
export async function openhumanLocalAiEmbed(
|
||||
inputs: string[]
|
||||
): Promise<CommandResponse<LocalAiEmbeddingResult>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<LocalAiEmbeddingResult>>({
|
||||
method: 'openhuman.local_ai_embed',
|
||||
params: { inputs },
|
||||
@@ -1226,9 +1202,6 @@ export async function openhumanLocalAiEmbed(
|
||||
export async function openhumanLocalAiTranscribe(
|
||||
audioPath: string
|
||||
): Promise<CommandResponse<LocalAiSpeechResult>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<LocalAiSpeechResult>>({
|
||||
method: 'openhuman.local_ai_transcribe',
|
||||
params: { audio_path: audioPath },
|
||||
@@ -1239,9 +1212,6 @@ export async function openhumanLocalAiTranscribeBytes(
|
||||
audioBytes: number[],
|
||||
extension?: string
|
||||
): Promise<CommandResponse<LocalAiSpeechResult>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<LocalAiSpeechResult>>({
|
||||
method: 'openhuman.local_ai_transcribe_bytes',
|
||||
params: { audio_bytes: audioBytes, extension },
|
||||
@@ -1252,9 +1222,6 @@ export async function openhumanLocalAiTts(
|
||||
text: string,
|
||||
outputPath?: string
|
||||
): Promise<CommandResponse<LocalAiTtsResult>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<LocalAiTtsResult>>({
|
||||
method: 'openhuman.local_ai_tts',
|
||||
params: { text, output_path: outputPath },
|
||||
@@ -1264,9 +1231,6 @@ export async function openhumanLocalAiTts(
|
||||
export async function openhumanLocalAiAssetsStatus(): Promise<
|
||||
CommandResponse<LocalAiAssetsStatus>
|
||||
> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<LocalAiAssetsStatus>>({
|
||||
method: 'openhuman.local_ai_assets_status',
|
||||
});
|
||||
@@ -1275,9 +1239,6 @@ export async function openhumanLocalAiAssetsStatus(): Promise<
|
||||
export async function openhumanLocalAiDownloadsProgress(): Promise<
|
||||
CommandResponse<LocalAiDownloadsProgress>
|
||||
> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<LocalAiDownloadsProgress>>({
|
||||
method: 'openhuman.local_ai_downloads_progress',
|
||||
});
|
||||
@@ -1286,9 +1247,6 @@ export async function openhumanLocalAiDownloadsProgress(): Promise<
|
||||
export async function openhumanLocalAiDownloadAsset(
|
||||
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
|
||||
): Promise<CommandResponse<LocalAiAssetsStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<LocalAiAssetsStatus>>({
|
||||
method: 'openhuman.local_ai_download_asset',
|
||||
params: { capability },
|
||||
@@ -1386,40 +1344,37 @@ export async function openhumanServiceInstall(): Promise<CommandResponse<Service
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
const result = await invoke<ServiceStatus>('openhuman_service_install');
|
||||
return wrapCommandResult(result);
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_install' });
|
||||
}
|
||||
|
||||
export async function openhumanServiceStart(): Promise<CommandResponse<ServiceStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
const result = await invoke<ServiceStatus>('openhuman_service_start');
|
||||
return wrapCommandResult(result);
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_start' });
|
||||
}
|
||||
|
||||
export async function openhumanServiceStop(): Promise<CommandResponse<ServiceStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
const result = await invoke<ServiceStatus>('openhuman_service_stop');
|
||||
return wrapCommandResult(result);
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_stop' });
|
||||
}
|
||||
|
||||
export async function openhumanServiceStatus(): Promise<CommandResponse<ServiceStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
const result = await invoke<ServiceStatus>('openhuman_service_status');
|
||||
return wrapCommandResult(result);
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_status' });
|
||||
}
|
||||
|
||||
export async function openhumanServiceUninstall(): Promise<CommandResponse<ServiceStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
const result = await invoke<ServiceStatus>('openhuman_service_uninstall');
|
||||
return wrapCommandResult(result);
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({
|
||||
method: 'openhuman.service_uninstall',
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanAgentServerStatus(): Promise<CommandResponse<AgentServerStatus>> {
|
||||
|
||||
@@ -17,13 +17,37 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
import { syncToolsToBackend } from '../lib/skills/sync';
|
||||
import { getBackendUrl } from '../services/backendUrl';
|
||||
import { callCoreRpc } from '../services/coreRpcClient';
|
||||
import { daemonHealthService } from '../services/daemonHealthService';
|
||||
import { store } from '../store';
|
||||
import { upsertChannelConnection } from '../store/channelConnectionsSlice';
|
||||
import { setSocketIdForUser, setStatusForUser } from '../store/socketSlice';
|
||||
import type { ChannelAuthMode, ChannelConnectionStatus, ChannelType } from '../types/channels';
|
||||
import { BACKEND_URL } from './config';
|
||||
|
||||
let runtimeSocketCommandsAvailable = true;
|
||||
|
||||
function isCommandNotFoundError(error: unknown): boolean {
|
||||
const message =
|
||||
typeof error === 'string'
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error ?? '');
|
||||
const normalized = message.toLowerCase();
|
||||
return normalized.includes('command') && normalized.includes('not found');
|
||||
}
|
||||
|
||||
function handleRuntimeSocketInvokeError(context: string, error: unknown): void {
|
||||
if (isCommandNotFoundError(error)) {
|
||||
runtimeSocketCommandsAvailable = false;
|
||||
console.warn(
|
||||
`[TauriSocket] ${context} unavailable: runtime socket commands are not registered in this build`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(`[TauriSocket] Failed to ${context}:`, error);
|
||||
}
|
||||
|
||||
// Check if we're running in Tauri
|
||||
export const isTauri = (): boolean => {
|
||||
@@ -51,13 +75,15 @@ export const isTauri = (): boolean => {
|
||||
*/
|
||||
export async function connectRustSocket(token: string): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
if (!runtimeSocketCommandsAvailable) return;
|
||||
|
||||
try {
|
||||
console.log('[TauriSocket] Connecting Rust socket to', BACKEND_URL);
|
||||
await invoke('runtime_socket_connect', { token, url: BACKEND_URL });
|
||||
const backendUrl = await getBackendUrl();
|
||||
console.log('[TauriSocket] Connecting Rust socket to', backendUrl);
|
||||
await invoke('runtime_socket_connect', { token, url: backendUrl });
|
||||
console.log('[TauriSocket] Rust socket connect call succeeded');
|
||||
} catch (error) {
|
||||
console.error('[TauriSocket] Failed to connect Rust socket:', error);
|
||||
handleRuntimeSocketInvokeError('connect Rust socket', error);
|
||||
// Ensure Redux status reflects the failure
|
||||
const uid = getSocketUserId();
|
||||
store.dispatch(setStatusForUser({ userId: uid, status: 'disconnected' }));
|
||||
@@ -69,12 +95,13 @@ export async function connectRustSocket(token: string): Promise<void> {
|
||||
*/
|
||||
export async function disconnectRustSocket(): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
if (!runtimeSocketCommandsAvailable) return;
|
||||
|
||||
try {
|
||||
await invoke('runtime_socket_disconnect');
|
||||
console.log('[TauriSocket] Rust socket disconnected');
|
||||
} catch (error) {
|
||||
console.error('[TauriSocket] Failed to disconnect Rust socket:', error);
|
||||
handleRuntimeSocketInvokeError('disconnect Rust socket', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +111,12 @@ export async function disconnectRustSocket(): Promise<void> {
|
||||
*/
|
||||
export async function emitViaRustSocket(event: string, data?: unknown): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
if (!runtimeSocketCommandsAvailable) return;
|
||||
|
||||
try {
|
||||
await invoke('runtime_socket_emit', { event, data: data ?? null });
|
||||
} catch (error) {
|
||||
console.error('[TauriSocket] Failed to emit via Rust socket:', error);
|
||||
handleRuntimeSocketInvokeError('emit via Rust socket', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,11 +128,12 @@ export async function getRustSocketState(): Promise<{
|
||||
socket_id: string | null;
|
||||
} | null> {
|
||||
if (!isTauri()) return null;
|
||||
if (!runtimeSocketCommandsAvailable) return null;
|
||||
|
||||
try {
|
||||
return await invoke('runtime_socket_state');
|
||||
} catch (error) {
|
||||
console.error('[TauriSocket] Failed to get Rust socket state:', error);
|
||||
handleRuntimeSocketInvokeError('get Rust socket state', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-748
@@ -1,753 +1,18 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Local HTTP mock server for E2E tests.
|
||||
* E2E mock server wrapper.
|
||||
*
|
||||
* Replaces the real backend so login-flow tests are fully self-contained.
|
||||
* Uses only `node:http` and `node:crypto` — no extra npm dependencies.
|
||||
*
|
||||
* Also handles WebSocket upgrades for the Socket.IO/Engine.IO endpoint so the
|
||||
* Rust-native socket manager doesn't crash from repeated connection failures.
|
||||
*
|
||||
* Default port: 18473 (high ephemeral, avoids Vite 1420 / Appium 4723 / backend 5005).
|
||||
* Re-exports the shared mock backend used by app unit tests, app E2E,
|
||||
* and Rust tests (via scripts/mock-api-server.mjs + scripts/test-rust-with-mock.sh).
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
|
||||
const DEFAULT_PORT = 18_473;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let requestLog = [];
|
||||
|
||||
export function getRequestLog() {
|
||||
return [...requestLog];
|
||||
}
|
||||
|
||||
export function clearRequestLog() {
|
||||
requestLog = [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock behavior toggles — tests can change responses at runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mockBehavior: Record<string, string> = {};
|
||||
|
||||
export function setMockBehavior(key: string, value: string) {
|
||||
mockBehavior[key] = value;
|
||||
}
|
||||
|
||||
export function resetMockBehavior() {
|
||||
mockBehavior = {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data — shapes taken from src/test/handlers.ts (MSW unit-test mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_JWT = 'e2e-mock-jwt-token';
|
||||
|
||||
const MOCK_USER = {
|
||||
_id: 'test-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().toISOString(),
|
||||
},
|
||||
autoDeleteTelegramMessagesAfterDays: 30,
|
||||
autoDeleteThreadsAfterDays: 30,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic mock data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a team object whose subscription reflects the current mockBehavior.
|
||||
* mockBehavior['plan'] → 'FREE' | 'BASIC' | 'PRO' (default: 'FREE')
|
||||
* mockBehavior['planActive'] → 'true' to mark subscription active
|
||||
* mockBehavior['planExpiry'] → ISO date string for renewal display
|
||||
*/
|
||||
function getMockTeam() {
|
||||
const plan = mockBehavior['plan'] || 'FREE';
|
||||
const isActive = mockBehavior['planActive'] === 'true';
|
||||
const expiry = mockBehavior['planExpiry'] || null;
|
||||
|
||||
return {
|
||||
team: {
|
||||
_id: 'team-1',
|
||||
name: 'Personal',
|
||||
slug: 'personal',
|
||||
createdBy: 'test-user-123',
|
||||
isPersonal: true,
|
||||
maxMembers: 1,
|
||||
subscription: { plan, hasActiveSubscription: isActive, planExpiry: expiry },
|
||||
usage: { dailyTokenLimit: 1000, remainingTokens: 1000, activeSessionCount: 0 },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
role: 'ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CORS helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CORS_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
};
|
||||
|
||||
function setCors(res) {
|
||||
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
setCors(res);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handling (HTTP)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise(resolve => {
|
||||
const chunks = [];
|
||||
req.on('data', c => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
const method = req.method ?? 'GET';
|
||||
const url = req.url ?? '/';
|
||||
const body = await readBody(req);
|
||||
|
||||
// Log every request for test assertions
|
||||
requestLog.push({ method, url, body, timestamp: Date.now() });
|
||||
|
||||
// CORS preflight
|
||||
if (method === 'OPTIONS') {
|
||||
setCors(res);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Socket.IO polling transport (GET /socket.io/?EIO=4&transport=polling)
|
||||
// Respond with Engine.IO OPEN packet so polling clients don't error out.
|
||||
if (url.startsWith('/socket.io/')) {
|
||||
const eioOpen = JSON.stringify({
|
||||
sid: 'mock-sid-' + Date.now(),
|
||||
upgrades: ['websocket'],
|
||||
pingInterval: 25000,
|
||||
pingTimeout: 20000,
|
||||
});
|
||||
// Engine.IO packet type 0 = OPEN, prefixed with byte count for polling
|
||||
const packet = `${eioOpen.length + 1}:0${eioOpen}`;
|
||||
setCors(res);
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /telegram/login-tokens/:token/consume
|
||||
if (method === 'POST' && /^\/telegram\/login-tokens\/[^/]+\/consume\/?$/.test(url)) {
|
||||
if (mockBehavior['token'] === 'expired') {
|
||||
json(res, 401, { success: false, error: 'Token expired or invalid' });
|
||||
return;
|
||||
}
|
||||
if (mockBehavior['token'] === 'invalid') {
|
||||
json(res, 401, { success: false, error: 'Invalid token' });
|
||||
return;
|
||||
}
|
||||
const jwt = mockBehavior['jwt'] ? `${MOCK_JWT}-${mockBehavior['jwt']}` : MOCK_JWT;
|
||||
json(res, 200, { success: true, data: { jwtToken: jwt } });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /telegram/me
|
||||
if (method === 'GET' && /^\/telegram\/me\/?(\?.*)?$/.test(url)) {
|
||||
if (mockBehavior['session'] === 'revoked') {
|
||||
json(res, 401, { success: false, error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { success: true, data: MOCK_USER });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /teams
|
||||
if (method === 'GET' && /^\/teams\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: [getMockTeam()] });
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /invite/redeem
|
||||
if (method === 'POST' && /^\/invite\/redeem\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { message: 'Invite code redeemed successfully' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /invite/my-codes
|
||||
if (method === 'GET' && /^\/invite\/my-codes\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /invite/status
|
||||
if (method === 'GET' && /^\/invite\/status/.test(url)) {
|
||||
json(res, 200, { success: true, data: { valid: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /telegram/settings/onboarding-complete
|
||||
if (method === 'POST' && /^\/telegram\/settings\/onboarding-complete\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /billing/current-plan (legacy alias)
|
||||
// GET /payments/stripe/currentPlan
|
||||
if (
|
||||
(method === 'GET' && /^\/billing\/current-plan\/?(\?.*)?$/.test(url)) ||
|
||||
(method === 'GET' && /^\/payments\/stripe\/currentPlan\/?(\?.*)?$/.test(url))
|
||||
) {
|
||||
const plan = mockBehavior['plan'] || 'FREE';
|
||||
const isActive = mockBehavior['planActive'] === 'true';
|
||||
const expiry = mockBehavior['planExpiry'] || null;
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
plan,
|
||||
hasActiveSubscription: isActive,
|
||||
planExpiry: expiry,
|
||||
subscription: isActive
|
||||
? {
|
||||
id: 'sub_mock_123',
|
||||
status: 'active',
|
||||
currentPeriodEnd: expiry || new Date(Date.now() + 30 * 86400000).toISOString(),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /payments/stripe/purchasePlan
|
||||
if (method === 'POST' && /^\/payments\/stripe\/purchasePlan\/?$/.test(url)) {
|
||||
if (mockBehavior['purchaseError'] === 'true') {
|
||||
json(res, 500, { success: false, error: 'Payment service unavailable' });
|
||||
return;
|
||||
}
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
checkoutUrl: 'http://127.0.0.1:18473/mock-checkout',
|
||||
sessionId: 'cs_mock_' + Date.now(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /payments/stripe/portal
|
||||
if (method === 'POST' && /^\/payments\/stripe\/portal\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { portalUrl: 'http://127.0.0.1:18473/mock-portal' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /payments/coinbase/charge
|
||||
if (method === 'POST' && /^\/payments\/coinbase\/charge\/?$/.test(url)) {
|
||||
if (mockBehavior['coinbaseError'] === 'true') {
|
||||
json(res, 500, { success: false, error: 'Coinbase service unavailable' });
|
||||
return;
|
||||
}
|
||||
const chargeId = 'coinbase_mock_' + Date.now();
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
gatewayTransactionId: chargeId,
|
||||
hostedUrl: 'http://127.0.0.1:18473/mock-coinbase-checkout',
|
||||
status: 'NEW',
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /payments/coinbase/charge/:id — crypto payment status check
|
||||
if (method === 'GET' && /^\/payments\/coinbase\/charge\/[^/]+\/?(\?.*)?$/.test(url)) {
|
||||
const status = mockBehavior['cryptoStatus'] || 'NEW';
|
||||
const underpaidAmount = mockBehavior['cryptoUnderpaidAmount'] || '0';
|
||||
const overpaidAmount = mockBehavior['cryptoOverpaidAmount'] || '0';
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
status,
|
||||
payment: {
|
||||
status,
|
||||
amountPaid:
|
||||
status === 'UNDERPAID' ? '150.00' : status === 'OVERPAID' ? '350.00' : '250.00',
|
||||
amountExpected: '250.00',
|
||||
currency: 'USDC',
|
||||
underpaidAmount,
|
||||
overpaidAmount,
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /auth/telegram/connect — OAuth connect URL for Telegram setup
|
||||
if (method === 'GET' && /^\/auth\/telegram\/connect\/?(\?.*)?$/.test(url)) {
|
||||
if (mockBehavior['telegramDuplicate'] === 'true') {
|
||||
json(res, 409, { success: false, error: 'Telegram account already linked to another user' });
|
||||
return;
|
||||
}
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { oauthUrl: 'http://127.0.0.1:18473/mock-telegram-oauth' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /telegram/command — process a Telegram command
|
||||
if (method === 'POST' && /^\/telegram\/command\/?$/.test(url)) {
|
||||
if (mockBehavior['telegramUnauthorized'] === 'true') {
|
||||
json(res, 403, { success: false, error: 'Unauthorized: insufficient permissions' });
|
||||
return;
|
||||
}
|
||||
if (mockBehavior['telegramCommandError'] === 'true') {
|
||||
json(res, 400, { success: false, error: 'Invalid command format' });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { success: true, data: { result: 'Command executed successfully' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /telegram/permissions — get current permission levels
|
||||
if (method === 'GET' && /^\/telegram\/permissions\/?(\?.*)?$/.test(url)) {
|
||||
const level = mockBehavior['telegramPermission'] || 'read';
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { level, canRead: true, canWrite: level !== 'read', canInitiate: level === 'admin' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /telegram/webhook/configure — configure webhook
|
||||
if (method === 'POST' && /^\/telegram\/webhook\/configure\/?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { webhookUrl: 'https://api.example.com/webhook/telegram', active: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /telegram/disconnect — disconnect Telegram skill
|
||||
if (method === 'POST' && /^\/telegram\/disconnect\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { disconnected: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /skills — list available skills
|
||||
if (method === 'GET' && /^\/skills\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: 'telegram',
|
||||
name: 'Telegram',
|
||||
status: mockBehavior['telegramSkillStatus'] || 'installed',
|
||||
setupComplete: mockBehavior['telegramSetupComplete'] === 'true',
|
||||
},
|
||||
{
|
||||
id: 'notion',
|
||||
name: 'Notion',
|
||||
status: mockBehavior['notionSkillStatus'] || 'installed',
|
||||
setupComplete: mockBehavior['notionSetupComplete'] === 'true',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
name: 'Email',
|
||||
status: mockBehavior['gmailSkillStatus'] || 'installed',
|
||||
setupComplete: mockBehavior['gmailSetupComplete'] === 'true',
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /mock-telegram-oauth — mock OAuth page
|
||||
if (method === 'GET' && /^\/mock-telegram-oauth\/?(\?.*)?$/.test(url)) {
|
||||
setCors(res);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<html><body><h1>Mock Telegram OAuth</h1></body></html>');
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /auth/notion/connect — OAuth connect URL for Notion setup
|
||||
if (method === 'GET' && /^\/auth\/notion\/connect\/?(\?.*)?$/.test(url)) {
|
||||
if (mockBehavior['notionTokenRevoked'] === 'true') {
|
||||
json(res, 401, { success: false, error: 'OAuth token has been revoked' });
|
||||
return;
|
||||
}
|
||||
const workspace = mockBehavior['notionWorkspace'] || "Test User's Workspace";
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { oauthUrl: 'http://127.0.0.1:18473/mock-notion-oauth', workspace },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /notion/permissions — get current Notion permission level
|
||||
if (method === 'GET' && /^\/notion\/permissions\/?(\?.*)?$/.test(url)) {
|
||||
const level = mockBehavior['notionPermission'] || 'read';
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
level,
|
||||
canRead: true,
|
||||
canWrite: level === 'write' || level === 'admin',
|
||||
canCreate: level === 'write' || level === 'admin',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /mock-notion-oauth — mock Notion OAuth page
|
||||
if (method === 'GET' && /^\/mock-notion-oauth\/?(\?.*)?$/.test(url)) {
|
||||
setCors(res);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(
|
||||
'<html><body><h1>Mock Notion OAuth</h1><p>Authorize workspace access</p></body></html>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /auth/google/connect — OAuth connect URL for Gmail/Email setup
|
||||
if (method === 'GET' && /^\/auth\/google\/connect\/?(\?.*)?$/.test(url)) {
|
||||
if (mockBehavior['gmailTokenRevoked'] === 'true') {
|
||||
json(res, 401, { success: false, error: 'OAuth token has been revoked' });
|
||||
return;
|
||||
}
|
||||
if (mockBehavior['gmailTokenExpired'] === 'true') {
|
||||
json(res, 401, { success: false, error: 'OAuth token has expired' });
|
||||
return;
|
||||
}
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { oauthUrl: 'http://127.0.0.1:18473/mock-google-oauth' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /gmail/permissions — get current Gmail/Email permission level
|
||||
if (method === 'GET' && /^\/gmail\/permissions\/?(\?.*)?$/.test(url)) {
|
||||
const level = mockBehavior['gmailPermission'] || 'read';
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
level,
|
||||
canRead: true,
|
||||
canWrite: level === 'write' || level === 'admin',
|
||||
canInitiate: level === 'admin',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /gmail/disconnect — disconnect Gmail/Email skill
|
||||
if (method === 'POST' && /^\/gmail\/disconnect\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { disconnected: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /gmail/emails — fetch emails (mock list)
|
||||
if (method === 'GET' && /^\/gmail\/emails\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
subject: 'Welcome to OpenHuman',
|
||||
from: 'team@openhuman.com',
|
||||
date: new Date().toISOString(),
|
||||
snippet: 'Welcome to the platform!',
|
||||
hasAttachments: false,
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
subject: 'Weekly Crypto Report',
|
||||
from: 'reports@crypto.com',
|
||||
date: new Date(Date.now() - 86400000).toISOString(),
|
||||
snippet: 'Your weekly summary is ready.',
|
||||
hasAttachments: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /mock-google-oauth — mock Google OAuth page
|
||||
if (method === 'GET' && /^\/mock-google-oauth\/?(\?.*)?$/.test(url)) {
|
||||
setCors(res);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<html><body><h1>Mock Google OAuth</h1><p>Authorize email access</p></body></html>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Catch-all — prevents app crashes from unexpected API calls
|
||||
json(res, 200, { success: true, data: {} });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket upgrade handler (minimal Engine.IO + Socket.IO)
|
||||
//
|
||||
// The Rust SocketManager connects via WebSocket to
|
||||
// ws://host/socket.io/?EIO=4&transport=websocket
|
||||
// and expects:
|
||||
// 1. Engine.IO OPEN packet (type 0): JSON with sid, pingInterval, etc.
|
||||
// 2. Socket.IO CONNECT ACK (type 40): JSON with sid
|
||||
// 3. Periodic Engine.IO PING (type 2) which we respond to with PONG (3)
|
||||
//
|
||||
// Without this, the Rust ws_loop retries forever and may destabilize the app.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleWebSocketUpgrade(req, socket, head) {
|
||||
// Only handle /socket.io/ WebSocket upgrades
|
||||
if (!req.url?.startsWith('/socket.io/')) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform WebSocket handshake (RFC 6455)
|
||||
const key = req.headers['sec-websocket-key'];
|
||||
if (!key) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptKey = crypto
|
||||
.createHash('sha1')
|
||||
.update(key + '258EAFA5-E914-47DA-95CA-5AB5DC085B11')
|
||||
.digest('base64');
|
||||
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||
'Upgrade: websocket\r\n' +
|
||||
'Connection: Upgrade\r\n' +
|
||||
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
||||
'\r\n'
|
||||
);
|
||||
|
||||
const mockSid = 'mock-ws-' + Date.now();
|
||||
|
||||
// Send Engine.IO OPEN packet (type 0)
|
||||
const eioOpen = JSON.stringify({
|
||||
sid: mockSid,
|
||||
upgrades: [],
|
||||
pingInterval: 25000,
|
||||
pingTimeout: 60000,
|
||||
maxPayload: 1000000,
|
||||
});
|
||||
sendWsText(socket, `0${eioOpen}`);
|
||||
|
||||
// Buffer for partial frames
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
socket.on('data', chunk => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
while (buffer.length >= 2) {
|
||||
const firstByte = buffer[0];
|
||||
const opcode = firstByte & 0x0f;
|
||||
const secondByte = buffer[1];
|
||||
const masked = (secondByte & 0x80) !== 0;
|
||||
let payloadLen = secondByte & 0x7f;
|
||||
let offset = 2;
|
||||
|
||||
if (payloadLen === 126) {
|
||||
if (buffer.length < 4) return; // need more data
|
||||
payloadLen = buffer.readUInt16BE(2);
|
||||
offset = 4;
|
||||
} else if (payloadLen === 127) {
|
||||
if (buffer.length < 10) return;
|
||||
payloadLen = Number(buffer.readBigUInt64BE(2));
|
||||
offset = 10;
|
||||
}
|
||||
|
||||
const maskSize = masked ? 4 : 0;
|
||||
const totalLen = offset + maskSize + payloadLen;
|
||||
if (buffer.length < totalLen) return; // need more data
|
||||
|
||||
let payload = buffer.subarray(offset + maskSize, totalLen);
|
||||
|
||||
if (masked) {
|
||||
const mask = buffer.subarray(offset, offset + 4);
|
||||
payload = Buffer.from(payload); // make writable copy
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
payload[i] ^= mask[i % 4];
|
||||
}
|
||||
}
|
||||
|
||||
// Consume the frame from the buffer
|
||||
buffer = buffer.subarray(totalLen);
|
||||
|
||||
// Handle by opcode
|
||||
if (opcode === 0x08) {
|
||||
// Close
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
if (opcode === 0x09) {
|
||||
// Ping → Pong
|
||||
sendWsFrame(socket, 0x0a, payload);
|
||||
continue;
|
||||
}
|
||||
if (opcode === 0x01) {
|
||||
// Text frame
|
||||
const text = payload.toString('utf-8');
|
||||
handleSocketIOMessage(socket, text, mockSid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', () => {});
|
||||
socket.on('close', () => {});
|
||||
}
|
||||
|
||||
function handleSocketIOMessage(socket, text, sid) {
|
||||
// Engine.IO PING (type "2") → respond with PONG ("3")
|
||||
if (text === '2') {
|
||||
sendWsText(socket, '3');
|
||||
return;
|
||||
}
|
||||
|
||||
// Socket.IO CONNECT (type "40") → respond with CONNECT ACK
|
||||
if (text.startsWith('40')) {
|
||||
sendWsText(socket, `40{"sid":"${sid}"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Socket.IO EVENT (type "42") → log but ignore
|
||||
// e.g. 42["tool:sync", {...}]
|
||||
}
|
||||
|
||||
function sendWsText(socket, text) {
|
||||
sendWsFrame(socket, 0x01, Buffer.from(text, 'utf-8'));
|
||||
}
|
||||
|
||||
function sendWsFrame(socket, opcode, payload) {
|
||||
if (socket.destroyed) return;
|
||||
|
||||
const len = payload.length;
|
||||
let header;
|
||||
|
||||
if (len < 126) {
|
||||
header = Buffer.alloc(2);
|
||||
header[0] = 0x80 | opcode; // FIN + opcode
|
||||
header[1] = len;
|
||||
} else if (len < 65536) {
|
||||
header = Buffer.alloc(4);
|
||||
header[0] = 0x80 | opcode;
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(len, 2);
|
||||
} else {
|
||||
header = Buffer.alloc(10);
|
||||
header[0] = 0x80 | opcode;
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(len), 2);
|
||||
}
|
||||
|
||||
try {
|
||||
socket.write(header);
|
||||
socket.write(payload);
|
||||
} catch {
|
||||
// socket may have been destroyed
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let server = null;
|
||||
const openSockets = new Set();
|
||||
|
||||
export function startMockServer(port = DEFAULT_PORT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server = http.createServer((req, res) => {
|
||||
handleRequest(req, res).catch(err => {
|
||||
console.error('[MockServer] Unhandled error:', err);
|
||||
json(res, 500, { success: false, error: 'Internal mock error' });
|
||||
});
|
||||
});
|
||||
|
||||
// Track all connections so stopMockServer can force-close them
|
||||
server.on('connection', socket => {
|
||||
openSockets.add(socket);
|
||||
socket.on('close', () => openSockets.delete(socket));
|
||||
});
|
||||
|
||||
// Handle WebSocket upgrades for Socket.IO
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
handleWebSocketUpgrade(req, socket, head);
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`[MockServer] Listening on http://127.0.0.1:${port}`);
|
||||
resolve({ port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function stopMockServer() {
|
||||
return new Promise(resolve => {
|
||||
if (!server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Destroy all open sockets so server.close() doesn't hang
|
||||
for (const socket of openSockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
openSockets.clear();
|
||||
server.close(() => {
|
||||
console.log('[MockServer] Stopped');
|
||||
server = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
export {
|
||||
clearRequestLog,
|
||||
getMockBehavior,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
setMockBehavior,
|
||||
setMockBehaviors,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../../../scripts/mock-api-core.mjs';
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
clickButton,
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
interface ServiceMockFailures {
|
||||
install?: string;
|
||||
start?: string;
|
||||
stop?: string;
|
||||
status?: string;
|
||||
uninstall?: string;
|
||||
}
|
||||
|
||||
interface ServiceMockState {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
agent_running: boolean;
|
||||
failures: ServiceMockFailures;
|
||||
}
|
||||
|
||||
const DEFAULT_MOCK_STATE: ServiceMockState = {
|
||||
installed: false,
|
||||
running: false,
|
||||
agent_running: false,
|
||||
failures: {},
|
||||
};
|
||||
|
||||
const mockStateFile =
|
||||
process.env.OPENHUMAN_SERVICE_MOCK_STATE_FILE ||
|
||||
path.join(process.env.OPENHUMAN_WORKSPACE || os.tmpdir(), 'service-mock-state.json');
|
||||
|
||||
function stepLog(message: string, context?: unknown): void {
|
||||
const stamp = new Date().toISOString();
|
||||
if (context === undefined) {
|
||||
console.log(`[ServiceConnectivityE2E][${stamp}] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[ServiceConnectivityE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2));
|
||||
}
|
||||
|
||||
async function writeMockState(state: ServiceMockState): Promise<void> {
|
||||
stepLog('Writing mock state', state);
|
||||
await fs.mkdir(path.dirname(mockStateFile), { recursive: true });
|
||||
await fs.writeFile(mockStateFile, JSON.stringify(state, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async function readMockState(): Promise<ServiceMockState> {
|
||||
const raw = await fs.readFile(mockStateFile, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as ServiceMockState;
|
||||
stepLog('Read mock state', parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function waitForServiceStateText(stateText: string, timeoutMs = 15_000): Promise<void> {
|
||||
await waitForText(stateText, timeoutMs);
|
||||
}
|
||||
|
||||
describe('Service connectivity flow (UI ↔ Rust service)', () => {
|
||||
before(async function beforeSuite() {
|
||||
if (process.env.OPENHUMAN_SERVICE_MOCK !== '1') {
|
||||
this.skip();
|
||||
}
|
||||
|
||||
stepLog('Starting suite with service mock mode enabled', {
|
||||
openhumanServiceMock: process.env.OPENHUMAN_SERVICE_MOCK,
|
||||
mockStateFile,
|
||||
});
|
||||
await writeMockState(DEFAULT_MOCK_STATE);
|
||||
await startMockServer();
|
||||
stepLog('Mock backend started');
|
||||
await waitForApp();
|
||||
stepLog('App process launched');
|
||||
|
||||
await triggerAuthDeepLink('service-connectivity-token');
|
||||
stepLog('Triggered auth deep link');
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
stepLog('App window and webview ready');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
stepLog('Stopping mock backend');
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
it('shows the blocking gate when service is not installed', async () => {
|
||||
await waitForText('OpenHuman Service Required', 20_000);
|
||||
await waitForServiceStateText('NotInstalled');
|
||||
|
||||
expect(await textExists('Install Service')).toBe(true);
|
||||
expect(await textExists('Start Service')).toBe(true);
|
||||
expect(await textExists('Stop Service')).toBe(true);
|
||||
expect(await textExists('Restart Service')).toBe(true);
|
||||
expect(await textExists('Uninstall Service')).toBe(true);
|
||||
});
|
||||
|
||||
it('installs the service from the gate', async () => {
|
||||
stepLog('Clicking Install Service');
|
||||
await clickButton('Install Service');
|
||||
await waitForServiceStateText('Stopped');
|
||||
|
||||
const state = await readMockState();
|
||||
expect(state.installed).toBe(true);
|
||||
expect(state.running).toBe(false);
|
||||
});
|
||||
|
||||
it('starts the service from the gate', async () => {
|
||||
stepLog('Clicking Start Service');
|
||||
await clickButton('Start Service');
|
||||
await waitForServiceStateText('Running');
|
||||
|
||||
const state = await readMockState();
|
||||
expect(state.installed).toBe(true);
|
||||
expect(state.running).toBe(true);
|
||||
});
|
||||
|
||||
it('stops the service from the gate', async () => {
|
||||
stepLog('Clicking Stop Service');
|
||||
await clickButton('Stop Service');
|
||||
await waitForServiceStateText('Stopped');
|
||||
|
||||
const state = await readMockState();
|
||||
expect(state.running).toBe(false);
|
||||
});
|
||||
|
||||
it('restarts the service from the gate', async () => {
|
||||
stepLog('Clicking Restart Service');
|
||||
await clickButton('Restart Service');
|
||||
await waitForServiceStateText('Running');
|
||||
|
||||
const state = await readMockState();
|
||||
expect(state.running).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps user blocked and surfaces error when core start fails', async () => {
|
||||
const state = await readMockState();
|
||||
await writeMockState({
|
||||
...state,
|
||||
running: false,
|
||||
failures: { ...state.failures, start: 'simulated start failure' },
|
||||
});
|
||||
|
||||
stepLog('Injecting start failure and refreshing gate');
|
||||
await clickButton('Refresh');
|
||||
await waitForServiceStateText('Stopped');
|
||||
|
||||
stepLog('Attempting start while failure is injected');
|
||||
await clickButton('Start Service');
|
||||
await waitForText('simulated start failure', 10_000);
|
||||
await waitForText('OpenHuman Service Required', 10_000);
|
||||
|
||||
const latest = await readMockState();
|
||||
expect(latest.running).toBe(false);
|
||||
});
|
||||
|
||||
it('uninstalls the service from the gate', async () => {
|
||||
const state = await readMockState();
|
||||
await writeMockState({ ...state, failures: { ...state.failures, start: undefined } });
|
||||
|
||||
stepLog('Clicking Uninstall Service');
|
||||
await clickButton('Uninstall Service');
|
||||
await waitForServiceStateText('NotInstalled');
|
||||
|
||||
const latest = await readMockState();
|
||||
expect(latest.installed).toBe(false);
|
||||
expect(latest.running).toBe(false);
|
||||
});
|
||||
|
||||
it('unblocks once service is running and agent is reported healthy', async () => {
|
||||
const state = await readMockState();
|
||||
await writeMockState({
|
||||
...state,
|
||||
installed: true,
|
||||
running: true,
|
||||
agent_running: true,
|
||||
failures: {},
|
||||
});
|
||||
|
||||
stepLog('Making service + agent healthy and refreshing');
|
||||
await clickButton('Refresh');
|
||||
|
||||
await browser.waitUntil(async () => !(await textExists('OpenHuman Service Required')), {
|
||||
timeout: 20_000,
|
||||
timeoutMsg: 'Service blocking gate did not clear after healthy status',
|
||||
});
|
||||
stepLog('Gate cleared as expected');
|
||||
});
|
||||
|
||||
it('shows blocking gate again when connection suddenly breaks', async () => {
|
||||
const state = await readMockState();
|
||||
await writeMockState({
|
||||
...state,
|
||||
installed: true,
|
||||
running: false,
|
||||
agent_running: false,
|
||||
failures: {},
|
||||
});
|
||||
stepLog('Injected sudden disconnect state; waiting for polling-based gate re-block');
|
||||
|
||||
await browser.waitUntil(async () => textExists('OpenHuman Service Required'), {
|
||||
timeout: 20_000,
|
||||
interval: 500,
|
||||
timeoutMsg: 'Service blocking gate did not reappear after sudden disconnect',
|
||||
});
|
||||
|
||||
await waitForServiceStateText('Stopped');
|
||||
await waitForText('Not Running', 10_000);
|
||||
stepLog('Gate reappeared with disconnected state');
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,13 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
maxWorkers: 1,
|
||||
minWorkers: 1,
|
||||
// Clear call history between tests but keep mock implementations from setup.ts
|
||||
// (mockReset/restoreMocks wipe vi.fn implementations and break shared mocks like getBackendUrl).
|
||||
clearMocks: true,
|
||||
mockReset: false,
|
||||
restoreMocks: false,
|
||||
setupFiles: ["src/test/setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
hookTimeout: 30000,
|
||||
|
||||
+16
-6
@@ -1,4 +1,5 @@
|
||||
import type { Options } from '@wdio/types';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -14,15 +15,25 @@ const tsconfigE2ePath = path.join(projectRoot, 'test', 'tsconfig.e2e.json');
|
||||
* On Windows/Linux, tauri-driver would be used instead (not covered here).
|
||||
*/
|
||||
function getAppPath(): string {
|
||||
const bundleBase = path.join(repoRoot, 'target', 'debug', 'bundle');
|
||||
const bundleBases = [
|
||||
path.join(projectRoot, 'src-tauri', 'target', 'debug', 'bundle'),
|
||||
path.join(repoRoot, 'target', 'debug', 'bundle'),
|
||||
];
|
||||
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return path.join(bundleBase, 'macos', 'OpenHuman.app');
|
||||
case 'darwin': {
|
||||
for (const base of bundleBases) {
|
||||
const appPath = path.join(base, 'macos', 'OpenHuman.app');
|
||||
if (fs.existsSync(appPath)) {
|
||||
return appPath;
|
||||
}
|
||||
}
|
||||
return path.join(bundleBases[0], 'macos', 'OpenHuman.app');
|
||||
}
|
||||
case 'win32':
|
||||
return path.join(repoRoot, 'target', 'debug', 'OpenHuman.exe');
|
||||
return path.join(projectRoot, 'src-tauri', 'target', 'debug', 'OpenHuman.exe');
|
||||
case 'linux':
|
||||
return path.join(repoRoot, 'target', 'debug', 'alpha-human');
|
||||
return path.join(projectRoot, 'src-tauri', 'target', 'debug', 'alpha-human');
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${process.platform}`);
|
||||
}
|
||||
@@ -40,7 +51,6 @@ export const config: Options.Testrunner = {
|
||||
// @ts-expect-error -- Appium capabilities are not in standard WebDriver types
|
||||
'appium:automationName': 'Mac2',
|
||||
'appium:app': getAppPath(),
|
||||
'appium:bundleId': 'com.openhuman.app',
|
||||
'appium:showServerLogs': true,
|
||||
},
|
||||
],
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
+15
-18
@@ -5,12 +5,8 @@ todo
|
||||
- skills need to specific via JSON-rpc the state changes they make to their state and data files in memory
|
||||
- skills need to be able to download custom mcp servers from the web
|
||||
|
||||
- integrate the payments flow properly
|
||||
skip the connect account page and goto the home page
|
||||
- integrate the payments flow properly, skip the connect account page and goto the home page
|
||||
|
||||
[] - get ai to summarize messages in the device and upload to the cloud
|
||||
[] - get all the remaining skills working
|
||||
[] - allow bundling of unverified skills
|
||||
[] - allow for new skills to be coded on their own
|
||||
[] - allow for multiple instances of a skill to be loaded
|
||||
[] - add a local model that can read through the screen and also go through voice using an API like whisper
|
||||
@@ -18,23 +14,24 @@ todo
|
||||
[] clean up the core so that we can run it as a binary on a server or as docker
|
||||
|
||||
[x] Separate the binary from the tauri codebase
|
||||
[ ] Integrate our custom memory engine into core
|
||||
[ ] Integrate our skills registry into core
|
||||
[] Integrate our custom memory engine into core
|
||||
[] Integrate our skills registry into core
|
||||
[x] Integrate accessibility service installation
|
||||
[ ] Add as a step and setting in the UI
|
||||
[] Add as a step and setting in the UI
|
||||
[x] Remove mentions of zeroclaw from the codebaes
|
||||
[x] Integrate local LLM into core
|
||||
[ ] Handle process/deamon properly
|
||||
[ ] install the linux philosophy of few modules that do their own thing really well sort of..
|
||||
[] Handle process/deamon properly
|
||||
[x] install the linux philosophy of few modules that do their own thing really well sort of..
|
||||
[x] Remove android / ios support from the codebase.
|
||||
[ ] e2e test to check if daemon and sidecar loading works properly
|
||||
[] e2e test to check if daemon and sidecar loading works properly
|
||||
[x] Find a better way to structure the cargo files
|
||||
[x] fix all the rust and cargo issues
|
||||
[ ] Add icon and app name to the various permission settings
|
||||
[ ] add self update based on github release. create a update action on the cli
|
||||
[ ] for each skill show information on how much data has been synced locally and information on how much syncs have happened so far etc..
|
||||
[ ] redo the docs once everything is done.
|
||||
[ ] remove unwanted feature flags from the rust binary
|
||||
[ ] fix the config properly
|
||||
[ ] Allow for Migrating from OpenClaw
|
||||
[] Add icon and app name to the various permission settings
|
||||
[] add self update based on github release. create a update action on the cli
|
||||
[] for each skill show information on how much data has been synced locally and information on how much syncs have happened so far etc..
|
||||
[x] redo the docs once everything is done.
|
||||
[x] remove unwanted feature flags from the rust binary
|
||||
[] fix the config properly
|
||||
[] Allow for Migrating from OpenClaw
|
||||
[] allow users to choose which version of LLM model they'd like to choose based on their CPU. better ram and gpu means higher parameter model can be used.
|
||||
[] in the client side app, make console.log follow a logger style logging where there's a namespace for every logger (like python)
|
||||
|
||||
+8
-4
@@ -4,6 +4,9 @@
|
||||
"workspaces": [
|
||||
"app"
|
||||
],
|
||||
"resolutions": {
|
||||
"@tauri-apps/api": "2.10.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn workspace openhuman-app build",
|
||||
"compile": "yarn workspace openhuman-app compile",
|
||||
@@ -15,10 +18,11 @@
|
||||
"tauri": "yarn workspace openhuman-app tauri",
|
||||
"test": "yarn workspace openhuman-app test",
|
||||
"test:coverage": "yarn workspace openhuman-app test:coverage",
|
||||
"test:e2e": "yarn workspace openhuman-app test:e2e",
|
||||
"test:e2e:all": "yarn workspace openhuman-app test:e2e:all",
|
||||
"test:e2e:all:flows": "yarn workspace openhuman-app test:e2e:all:flows",
|
||||
"test:e2e:build": "yarn workspace openhuman-app test:e2e:build",
|
||||
"test:rust": "yarn workspace openhuman-app test:rust",
|
||||
"mock:api": "node scripts/mock-api-server.mjs",
|
||||
"typecheck": "yarn workspace openhuman-app compile"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
import crypto from "node:crypto";
|
||||
import http from "node:http";
|
||||
|
||||
const DEFAULT_PORT = 18473;
|
||||
const MOCK_JWT = "e2e-mock-jwt-token";
|
||||
|
||||
let requestLog = [];
|
||||
let mockBehavior = {};
|
||||
let server = null;
|
||||
const openSockets = new Set();
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
};
|
||||
|
||||
function setCors(res) {
|
||||
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
setCors(res);
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function html(res, status, body) {
|
||||
setCors(res);
|
||||
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function requestOrigin(req) {
|
||||
const host = req.headers.host || "127.0.0.1:18473";
|
||||
return `http://${host}`;
|
||||
}
|
||||
|
||||
function getMockUser() {
|
||||
return {
|
||||
_id: "user-123",
|
||||
telegramId: 12345678,
|
||||
hasAccess: true,
|
||||
magicWord: "alpha",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
username: "testuser",
|
||||
role: "user",
|
||||
activeTeamId: "team-1",
|
||||
referral: {},
|
||||
subscription: { hasActiveSubscription: false, plan: "FREE" },
|
||||
settings: {
|
||||
dailySummariesEnabled: false,
|
||||
dailySummaryChatIds: [],
|
||||
autoCompleteEnabled: false,
|
||||
autoCompleteVisibility: "always",
|
||||
autoCompleteWhitelistChatIds: [],
|
||||
autoCompleteBlacklistChatIds: [],
|
||||
},
|
||||
usage: {
|
||||
cycleBudgetUsd: 10,
|
||||
spentThisCycleUsd: 0,
|
||||
spentTodayUsd: 0,
|
||||
cycleStartDate: new Date().toISOString(),
|
||||
},
|
||||
autoDeleteTelegramMessagesAfterDays: 30,
|
||||
autoDeleteThreadsAfterDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
function getMockTeam() {
|
||||
const plan = mockBehavior.plan || "FREE";
|
||||
const isActive = mockBehavior.planActive === "true";
|
||||
const expiry = mockBehavior.planExpiry || null;
|
||||
return {
|
||||
team: {
|
||||
_id: "team-1",
|
||||
name: "Personal",
|
||||
slug: "personal",
|
||||
createdBy: "test-user-123",
|
||||
isPersonal: true,
|
||||
maxMembers: 1,
|
||||
subscription: { plan, hasActiveSubscription: isActive, planExpiry: expiry },
|
||||
usage: { dailyTokenLimit: 1000, remainingTokens: 1000, activeSessionCount: 0 },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
role: "ADMIN",
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestLog() {
|
||||
return [...requestLog];
|
||||
}
|
||||
|
||||
function clearRequestLog() {
|
||||
requestLog = [];
|
||||
}
|
||||
|
||||
function setMockBehavior(key, value) {
|
||||
mockBehavior[key] = String(value);
|
||||
}
|
||||
|
||||
function setMockBehaviors(behavior, mode = "merge") {
|
||||
if (mode === "replace") {
|
||||
mockBehavior = {};
|
||||
}
|
||||
for (const [key, value] of Object.entries(behavior || {})) {
|
||||
mockBehavior[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function resetMockBehavior() {
|
||||
mockBehavior = {};
|
||||
}
|
||||
|
||||
function getMockBehavior() {
|
||||
return { ...mockBehavior };
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
||||
});
|
||||
}
|
||||
|
||||
function tryParseJson(raw) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
const method = req.method ?? "GET";
|
||||
const url = req.url ?? "/";
|
||||
const body = await readBody(req);
|
||||
const parsedBody = tryParseJson(body);
|
||||
const origin = requestOrigin(req);
|
||||
|
||||
requestLog.push({ method, url, body, timestamp: Date.now() });
|
||||
|
||||
if (method === "OPTIONS") {
|
||||
setCors(res);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/__admin\/health\/?$/.test(url)) {
|
||||
json(res, 200, { ok: true, port: server?.address()?.port ?? null });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/__admin\/requests\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: getRequestLog() });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/__admin\/behavior\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: getMockBehavior() });
|
||||
return;
|
||||
}
|
||||
if (method === "POST" && /^\/__admin\/reset\/?$/.test(url)) {
|
||||
const keepBehavior = parsedBody?.keepBehavior === true;
|
||||
const keepRequests = parsedBody?.keepRequests === true;
|
||||
if (!keepBehavior) resetMockBehavior();
|
||||
if (!keepRequests) clearRequestLog();
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { behavior: getMockBehavior(), requestCount: getRequestLog().length },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "POST" && /^\/__admin\/behavior\/?$/.test(url)) {
|
||||
if (parsedBody?.behavior && typeof parsedBody.behavior === "object") {
|
||||
setMockBehaviors(parsedBody.behavior, parsedBody.mode);
|
||||
} else if (parsedBody?.key) {
|
||||
setMockBehavior(parsedBody.key, parsedBody.value ?? "");
|
||||
}
|
||||
json(res, 200, { success: true, data: getMockBehavior() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith("/socket.io/")) {
|
||||
const eioOpen = JSON.stringify({
|
||||
sid: "mock-sid-" + Date.now(),
|
||||
upgrades: ["websocket"],
|
||||
pingInterval: 25000,
|
||||
pingTimeout: 20000,
|
||||
});
|
||||
const packet = `${eioOpen.length + 1}:0${eioOpen}`;
|
||||
setCors(res);
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/telegram\/login-tokens\/[^/]+\/consume\/?$/.test(url)) {
|
||||
if (mockBehavior.token === "expired") {
|
||||
json(res, 401, { success: false, error: "Token expired or invalid" });
|
||||
return;
|
||||
}
|
||||
if (mockBehavior.token === "invalid") {
|
||||
json(res, 401, { success: false, error: "Invalid token" });
|
||||
return;
|
||||
}
|
||||
const jwt = mockBehavior.jwt ? `${MOCK_JWT}-${mockBehavior.jwt}` : MOCK_JWT;
|
||||
json(res, 200, { success: true, data: { jwtToken: jwt } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/auth\/desktop-exchange\/?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
sessionToken: "mock-session-token",
|
||||
user: { id: "user-123", firstName: "Test", username: "testuser" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/telegram\/me\/?(\?.*)?$/.test(url)) {
|
||||
if (mockBehavior.telegramMeStatus) {
|
||||
const status = Number(mockBehavior.telegramMeStatus) || 500;
|
||||
json(res, status, {
|
||||
success: false,
|
||||
error: mockBehavior.telegramMeError || "Mock telegram/me failure",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mockBehavior.session === "revoked") {
|
||||
json(res, 401, { success: false, error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { success: true, data: getMockUser() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/settings\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { _id: "e2e-user-1", username: "e2e" } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/teams\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: [getMockTeam()] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/auth\/integrations\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/openai\/v1\/models\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { data: [{ id: "e2e-mock-model", object: "model" }] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/openai\/v1\/chat\/completions\/?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
choices: [{ message: { role: "assistant", content: "Hello from e2e mock agent" } }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/auth\/[^/]+\/login\/?(\?.*)?$/.test(url)) {
|
||||
const redirectUrl = `${origin}/mock-oauth`;
|
||||
if (url.includes("responseType=json")) {
|
||||
json(res, 200, { success: true, data: { oauthUrl: redirectUrl } });
|
||||
return;
|
||||
}
|
||||
setCors(res);
|
||||
res.writeHead(302, { Location: redirectUrl });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/auth\/telegram\/connect\/?(\?.*)?$/.test(url)) {
|
||||
if (mockBehavior.telegramDuplicate === "true") {
|
||||
json(res, 409, { success: false, error: "Telegram account already linked to another user" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { success: true, data: { oauthUrl: `${origin}/mock-telegram-oauth` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/auth\/notion\/connect\/?(\?.*)?$/.test(url)) {
|
||||
if (mockBehavior.notionTokenRevoked === "true") {
|
||||
json(res, 401, { success: false, error: "OAuth token has been revoked" });
|
||||
return;
|
||||
}
|
||||
const workspace = mockBehavior.notionWorkspace || "Test User's Workspace";
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { oauthUrl: `${origin}/mock-notion-oauth`, workspace },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/auth\/google\/connect\/?(\?.*)?$/.test(url)) {
|
||||
if (mockBehavior.gmailTokenRevoked === "true") {
|
||||
json(res, 401, { success: false, error: "OAuth token has been revoked" });
|
||||
return;
|
||||
}
|
||||
if (mockBehavior.gmailTokenExpired === "true") {
|
||||
json(res, 401, { success: false, error: "OAuth token has expired" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { success: true, data: { oauthUrl: `${origin}/mock-google-oauth` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/telegram\/command\/?$/.test(url)) {
|
||||
if (mockBehavior.telegramUnauthorized === "true") {
|
||||
json(res, 403, { success: false, error: "Unauthorized: insufficient permissions" });
|
||||
return;
|
||||
}
|
||||
if (mockBehavior.telegramCommandError === "true") {
|
||||
json(res, 400, { success: false, error: "Invalid command format" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { success: true, data: { result: "Command executed successfully" } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/telegram\/permissions\/?(\?.*)?$/.test(url)) {
|
||||
const level = mockBehavior.telegramPermission || "read";
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { level, canRead: true, canWrite: level !== "read", canInitiate: level === "admin" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/telegram\/webhook\/configure\/?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { webhookUrl: "https://api.example.com/webhook/telegram", active: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/telegram\/disconnect\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { disconnected: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/notion\/permissions\/?(\?.*)?$/.test(url)) {
|
||||
const level = mockBehavior.notionPermission || "read";
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { level, canRead: true, canWrite: level !== "read", canCreate: level !== "read" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/gmail\/permissions\/?(\?.*)?$/.test(url)) {
|
||||
const level = mockBehavior.gmailPermission || "read";
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { level, canRead: true, canWrite: level !== "read", canInitiate: level === "admin" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/gmail\/disconnect\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { disconnected: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/gmail\/emails\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: "msg-1",
|
||||
subject: "Welcome to OpenHuman",
|
||||
from: "team@openhuman.com",
|
||||
date: new Date().toISOString(),
|
||||
snippet: "Welcome to the platform!",
|
||||
hasAttachments: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/skills\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
status: mockBehavior.telegramSkillStatus || "installed",
|
||||
setupComplete: mockBehavior.telegramSetupComplete === "true",
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Notion",
|
||||
status: mockBehavior.notionSkillStatus || "installed",
|
||||
setupComplete: mockBehavior.notionSetupComplete === "true",
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
name: "Email",
|
||||
status: mockBehavior.gmailSkillStatus || "installed",
|
||||
setupComplete: mockBehavior.gmailSetupComplete === "true",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/invite\/redeem\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { message: "Invite code redeemed successfully" } });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/invite\/my-codes\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/invite\/status/.test(url)) {
|
||||
json(res, 200, { success: true, data: { valid: true } });
|
||||
return;
|
||||
}
|
||||
if (method === "POST" && /^\/telegram\/settings\/onboarding-complete\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(method === "GET" && /^\/billing\/current-plan\/?(\?.*)?$/.test(url)) ||
|
||||
(method === "GET" && /^\/payments\/stripe\/currentPlan\/?(\?.*)?$/.test(url))
|
||||
) {
|
||||
const plan = mockBehavior.plan || "FREE";
|
||||
const isActive = mockBehavior.planActive === "true";
|
||||
const expiry = mockBehavior.planExpiry || null;
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
plan,
|
||||
hasActiveSubscription: isActive,
|
||||
planExpiry: expiry,
|
||||
subscription: isActive
|
||||
? {
|
||||
id: "sub_mock_123",
|
||||
status: "active",
|
||||
currentPeriodEnd: expiry || new Date(Date.now() + 30 * 86400000).toISOString(),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/payments\/stripe\/purchasePlan\/?$/.test(url)) {
|
||||
if (mockBehavior.purchaseError === "true") {
|
||||
json(res, 500, { success: false, error: "Payment service unavailable" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: { checkoutUrl: `${origin}/mock-checkout`, sessionId: `cs_mock_${Date.now()}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/payments\/stripe\/portal\/?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: { portalUrl: `${origin}/mock-portal` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && /^\/payments\/coinbase\/charge\/?$/.test(url)) {
|
||||
if (mockBehavior.coinbaseError === "true") {
|
||||
json(res, 500, { success: false, error: "Coinbase service unavailable" });
|
||||
return;
|
||||
}
|
||||
const chargeId = `coinbase_mock_${Date.now()}`;
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
gatewayTransactionId: chargeId,
|
||||
hostedUrl: `${origin}/mock-coinbase-checkout`,
|
||||
status: "NEW",
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/payments\/coinbase\/charge\/[^/]+\/?(\?.*)?$/.test(url)) {
|
||||
const status = mockBehavior.cryptoStatus || "NEW";
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
status,
|
||||
payment: {
|
||||
status,
|
||||
amountPaid: status === "UNDERPAID" ? "150.00" : status === "OVERPAID" ? "350.00" : "250.00",
|
||||
amountExpected: "250.00",
|
||||
currency: "USDC",
|
||||
underpaidAmount: mockBehavior.cryptoUnderpaidAmount || "0",
|
||||
overpaidAmount: mockBehavior.cryptoOverpaidAmount || "0",
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && /^\/mock-(telegram|notion|google)-oauth\/?(\?.*)?$/.test(url)) {
|
||||
html(res, 200, "<html><body><h1>Mock OAuth</h1></body></html>");
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && /^\/mock-oauth\/?(\?.*)?$/.test(url)) {
|
||||
html(res, 200, "<html><body><h1>Mock OAuth Redirect</h1></body></html>");
|
||||
return;
|
||||
}
|
||||
|
||||
json(res, 200, { success: true, data: {} });
|
||||
}
|
||||
|
||||
function handleSocketIOMessage(socket, text, sid) {
|
||||
if (text === "2") {
|
||||
sendWsText(socket, "3");
|
||||
return;
|
||||
}
|
||||
if (text.startsWith("40")) {
|
||||
sendWsText(socket, `40{"sid":"${sid}"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sendWsText(socket, text) {
|
||||
sendWsFrame(socket, 0x01, Buffer.from(text, "utf-8"));
|
||||
}
|
||||
|
||||
function sendWsFrame(socket, opcode, payload) {
|
||||
if (socket.destroyed) return;
|
||||
|
||||
const len = payload.length;
|
||||
let header;
|
||||
if (len < 126) {
|
||||
header = Buffer.alloc(2);
|
||||
header[0] = 0x80 | opcode;
|
||||
header[1] = len;
|
||||
} else if (len < 65536) {
|
||||
header = Buffer.alloc(4);
|
||||
header[0] = 0x80 | opcode;
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(len, 2);
|
||||
} else {
|
||||
header = Buffer.alloc(10);
|
||||
header[0] = 0x80 | opcode;
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(len), 2);
|
||||
}
|
||||
try {
|
||||
socket.write(header);
|
||||
socket.write(payload);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketUpgrade(req, socket) {
|
||||
if (!req.url?.startsWith("/socket.io/")) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const key = req.headers["sec-websocket-key"];
|
||||
if (!key) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const acceptKey = crypto
|
||||
.createHash("sha1")
|
||||
.update(key + "258EAFA5-E914-47DA-95CA-5AB5DC085B11")
|
||||
.digest("base64");
|
||||
socket.write(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
||||
"\r\n",
|
||||
);
|
||||
|
||||
const mockSid = "mock-ws-" + Date.now();
|
||||
const eioOpen = JSON.stringify({
|
||||
sid: mockSid,
|
||||
upgrades: [],
|
||||
pingInterval: 25000,
|
||||
pingTimeout: 60000,
|
||||
maxPayload: 1000000,
|
||||
});
|
||||
sendWsText(socket, `0${eioOpen}`);
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
while (buffer.length >= 2) {
|
||||
const firstByte = buffer[0];
|
||||
const opcode = firstByte & 0x0f;
|
||||
const secondByte = buffer[1];
|
||||
const masked = (secondByte & 0x80) !== 0;
|
||||
let payloadLen = secondByte & 0x7f;
|
||||
let offset = 2;
|
||||
|
||||
if (payloadLen === 126) {
|
||||
if (buffer.length < 4) return;
|
||||
payloadLen = buffer.readUInt16BE(2);
|
||||
offset = 4;
|
||||
} else if (payloadLen === 127) {
|
||||
if (buffer.length < 10) return;
|
||||
payloadLen = Number(buffer.readBigUInt64BE(2));
|
||||
offset = 10;
|
||||
}
|
||||
|
||||
const maskSize = masked ? 4 : 0;
|
||||
const totalLen = offset + maskSize + payloadLen;
|
||||
if (buffer.length < totalLen) return;
|
||||
let payload = buffer.subarray(offset + maskSize, totalLen);
|
||||
if (masked) {
|
||||
const mask = buffer.subarray(offset, offset + 4);
|
||||
payload = Buffer.from(payload);
|
||||
for (let i = 0; i < payload.length; i += 1) {
|
||||
payload[i] ^= mask[i % 4];
|
||||
}
|
||||
}
|
||||
buffer = buffer.subarray(totalLen);
|
||||
if (opcode === 0x08) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
if (opcode === 0x09) {
|
||||
sendWsFrame(socket, 0x0a, payload);
|
||||
continue;
|
||||
}
|
||||
if (opcode === 0x01) {
|
||||
handleSocketIOMessage(socket, payload.toString("utf-8"), mockSid);
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on("error", () => {});
|
||||
socket.on("close", () => {});
|
||||
}
|
||||
|
||||
function startMockServer(port = DEFAULT_PORT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (server) {
|
||||
resolve({ port: server.address()?.port ?? port, alreadyRunning: true });
|
||||
return;
|
||||
}
|
||||
server = http.createServer((req, res) => {
|
||||
handleRequest(req, res).catch((err) => {
|
||||
console.error("[MockServer] Unhandled error:", err);
|
||||
json(res, 500, { success: false, error: "Internal mock error" });
|
||||
});
|
||||
});
|
||||
server.on("connection", (socket) => {
|
||||
openSockets.add(socket);
|
||||
socket.on("close", () => openSockets.delete(socket));
|
||||
});
|
||||
server.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket));
|
||||
server.on("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`[MockServer] Listening on http://127.0.0.1:${port}`);
|
||||
resolve({ port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stopMockServer() {
|
||||
return new Promise((resolve) => {
|
||||
if (!server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
for (const socket of openSockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
openSockets.clear();
|
||||
server.close(() => {
|
||||
console.log("[MockServer] Stopped");
|
||||
server = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_PORT,
|
||||
clearRequestLog,
|
||||
getMockBehavior,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
setMockBehavior,
|
||||
setMockBehaviors,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
};
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
import process from "node:process";
|
||||
|
||||
import { DEFAULT_PORT, startMockServer, stopMockServer } from "./mock-api-core.mjs";
|
||||
|
||||
function readPortArg() {
|
||||
const idx = process.argv.findIndex((arg) => arg === "--port" || arg === "-p");
|
||||
if (idx >= 0 && process.argv[idx + 1]) {
|
||||
const value = Number(process.argv[idx + 1]);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
const envPort = Number(process.env.MOCK_API_PORT || process.env.E2E_MOCK_PORT || DEFAULT_PORT);
|
||||
return Number.isInteger(envPort) && envPort > 0 ? envPort : DEFAULT_PORT;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = readPortArg();
|
||||
await startMockServer(port);
|
||||
const shutdown = async () => {
|
||||
await stopMockServer();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[mock-api-server] Failed to start:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run Rust tests against the shared mock backend.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-rust-with-mock.sh
|
||||
# ./scripts/test-rust-with-mock.sh --test json_rpc_e2e
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
MOCK_API_PORT="${MOCK_API_PORT:-18505}"
|
||||
MOCK_API_URL="http://127.0.0.1:${MOCK_API_PORT}"
|
||||
MOCK_LOG="${MOCK_LOG:-/tmp/openhuman-mock-api.log}"
|
||||
MOCK_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$MOCK_PID" ]; then
|
||||
kill "$MOCK_PID" 2>/dev/null || true
|
||||
wait "$MOCK_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting mock API server on ${MOCK_API_URL} ..."
|
||||
node "$SCRIPT_DIR/mock-api-server.mjs" --port "$MOCK_API_PORT" >"$MOCK_LOG" 2>&1 &
|
||||
MOCK_PID=$!
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "${MOCK_API_URL}/__admin/health" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "ERROR: mock API server did not become healthy in time." >&2
|
||||
echo "See logs: $MOCK_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
export BACKEND_URL="$MOCK_API_URL"
|
||||
export VITE_BACKEND_URL="$MOCK_API_URL"
|
||||
|
||||
echo "Running Rust tests with BACKEND_URL=$BACKEND_URL"
|
||||
cd "$REPO_ROOT"
|
||||
source "$HOME/.cargo/env" 2>/dev/null || true
|
||||
cargo test --manifest-path Cargo.toml --workspace "$@"
|
||||
+125
-4
@@ -1,8 +1,10 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{header, HeaderValue, Method, StatusCode};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use axum::{extract::Request, Json, Router};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::core::all;
|
||||
@@ -87,17 +89,53 @@ pub fn build_core_http_router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root_handler))
|
||||
.route("/health", get(health_handler))
|
||||
.route("/schema", get(schema_handler))
|
||||
.route("/rpc", post(rpc_handler))
|
||||
.fallback(not_found_handler)
|
||||
.layer(middleware::from_fn(cors_middleware))
|
||||
.with_state(AppState {
|
||||
core_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cors_middleware(req: Request, next: Next) -> Response {
|
||||
if req.method() == Method::OPTIONS {
|
||||
return with_cors_headers(StatusCode::NO_CONTENT.into_response());
|
||||
}
|
||||
|
||||
let response = next.run(req).await;
|
||||
with_cors_headers(response)
|
||||
}
|
||||
|
||||
fn with_cors_headers(mut response: Response) -> Response {
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_static("*"),
|
||||
);
|
||||
headers.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
HeaderValue::from_static("GET, POST, OPTIONS"),
|
||||
);
|
||||
headers.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_HEADERS,
|
||||
HeaderValue::from_static("Content-Type, Authorization"),
|
||||
);
|
||||
headers.insert(
|
||||
header::ACCESS_CONTROL_MAX_AGE,
|
||||
HeaderValue::from_static("86400"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
async fn health_handler() -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn schema_handler(State(_state): State<AppState>) -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(build_http_schema_dump())).into_response()
|
||||
}
|
||||
|
||||
async fn root_handler() -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::OK,
|
||||
@@ -106,6 +144,7 @@ async fn root_handler() -> impl IntoResponse {
|
||||
"ok": true,
|
||||
"endpoints": {
|
||||
"health": "/health",
|
||||
"schema": "/schema",
|
||||
"rpc": "/rpc"
|
||||
},
|
||||
"usage": {
|
||||
@@ -125,7 +164,7 @@ async fn not_found_handler() -> impl IntoResponse {
|
||||
Json(json!({
|
||||
"ok": false,
|
||||
"error": "not_found",
|
||||
"message": "Route not found. Try /, /health, or /rpc."
|
||||
"message": "Route not found. Try /, /health, /schema, or /rpc."
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -165,11 +204,74 @@ pub async fn run_server(port: Option<u16>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HttpSchemaDump {
|
||||
methods: Vec<HttpMethodSchema>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HttpMethodSchema {
|
||||
method: String,
|
||||
namespace: String,
|
||||
function: String,
|
||||
description: String,
|
||||
inputs: Vec<crate::core::FieldSchema>,
|
||||
outputs: Vec<crate::core::FieldSchema>,
|
||||
}
|
||||
|
||||
fn build_http_schema_dump() -> HttpSchemaDump {
|
||||
let mut methods = vec![
|
||||
HttpMethodSchema {
|
||||
method: "core.ping".to_string(),
|
||||
namespace: "core".to_string(),
|
||||
function: "ping".to_string(),
|
||||
description: "Liveness probe for the core JSON-RPC server.".to_string(),
|
||||
inputs: vec![],
|
||||
outputs: vec![crate::core::FieldSchema {
|
||||
name: "ok",
|
||||
ty: crate::core::TypeSchema::Bool,
|
||||
comment: "Always true when the server is reachable.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
HttpMethodSchema {
|
||||
method: "core.version".to_string(),
|
||||
namespace: "core".to_string(),
|
||||
function: "version".to_string(),
|
||||
description: "Returns the core binary version.".to_string(),
|
||||
inputs: vec![],
|
||||
outputs: vec![crate::core::FieldSchema {
|
||||
name: "version",
|
||||
ty: crate::core::TypeSchema::String,
|
||||
comment: "Semantic version string for the running core binary.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
methods.extend(
|
||||
all::all_controller_schemas()
|
||||
.into_iter()
|
||||
.map(|schema| HttpMethodSchema {
|
||||
method: all::rpc_method_name(&schema),
|
||||
namespace: schema.namespace.to_string(),
|
||||
function: schema.function.to_string(),
|
||||
description: schema.description.to_string(),
|
||||
inputs: schema.inputs,
|
||||
outputs: schema.outputs,
|
||||
}),
|
||||
);
|
||||
|
||||
methods.sort_by(|a, b| a.method.cmp(&b.method));
|
||||
|
||||
HttpSchemaDump { methods }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{default_state, invoke_method};
|
||||
use super::{build_http_schema_dump, default_state, invoke_method};
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_health_snapshot_via_registry() {
|
||||
@@ -266,4 +368,23 @@ mod tests {
|
||||
.expect_err("missing capability should fail");
|
||||
assert!(err.contains("missing required param 'capability'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_schema_dump_includes_openhuman_and_core_methods() {
|
||||
let dump = build_http_schema_dump();
|
||||
let methods = dump.methods;
|
||||
assert!(
|
||||
methods
|
||||
.iter()
|
||||
.any(|m| m.method == "core.version" && m.namespace == "core"),
|
||||
"schema dump should include core methods"
|
||||
);
|
||||
|
||||
assert!(
|
||||
methods
|
||||
.iter()
|
||||
.any(|m| m.method == "openhuman.health_snapshot"),
|
||||
"schema dump should include migrated openhuman methods"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -1,4 +1,6 @@
|
||||
//! Shared core-level schemas and contracts used across adapters (RPC, CLI, etc.).
|
||||
use serde::Serialize;
|
||||
|
||||
pub mod all;
|
||||
pub mod cli;
|
||||
pub mod dispatch;
|
||||
@@ -10,7 +12,7 @@ pub mod types;
|
||||
///
|
||||
/// This shape is transport-agnostic and can be consumed by RPC and CLI layers
|
||||
/// in different ways.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ControllerSchema {
|
||||
/// Domain/group identifier, e.g. `memory`, `config`, `credentials`.
|
||||
pub namespace: &'static str,
|
||||
@@ -32,7 +34,7 @@ impl ControllerSchema {
|
||||
}
|
||||
|
||||
/// Schema for one input/output field.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct FieldSchema {
|
||||
/// Field name.
|
||||
pub name: &'static str,
|
||||
@@ -47,7 +49,7 @@ pub struct FieldSchema {
|
||||
}
|
||||
|
||||
/// Type-system shape used by controller input/output schema fields.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum TypeSchema {
|
||||
Bool,
|
||||
I64,
|
||||
|
||||
@@ -320,6 +320,12 @@ pub async fn load_and_apply_browser_settings(
|
||||
apply_browser_settings(&mut config, update).await
|
||||
}
|
||||
|
||||
pub async fn load_and_resolve_api_url() -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let config = load_config_with_timeout().await?;
|
||||
let resolved = crate::api::config::effective_api_url(&config.api_url);
|
||||
Ok(RpcOutcome::new(json!({ "api_url": resolved }), Vec::new()))
|
||||
}
|
||||
|
||||
/// Resolves workspace (load config or fallback), validates flag name, returns whether the flag file exists.
|
||||
pub async fn workspace_onboarding_flag_resolve(
|
||||
flag_name: Option<String>,
|
||||
@@ -383,8 +389,10 @@ pub fn workspace_onboarding_flag_exists(
|
||||
}
|
||||
|
||||
pub fn agent_server_status() -> RpcOutcome<serde_json::Value> {
|
||||
let running = crate::openhuman::service::mock::mock_agent_running().unwrap_or(true);
|
||||
log::info!("[config] agent_server_status requested: running={running}");
|
||||
let payload = json!({
|
||||
"running": true,
|
||||
"running": running,
|
||||
"url": core_rpc_url_from_env(),
|
||||
});
|
||||
RpcOutcome::single_log(payload, "agent server status checked")
|
||||
|
||||
@@ -69,6 +69,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("update_tunnel_settings"),
|
||||
schemas("update_runtime_settings"),
|
||||
schemas("update_browser_settings"),
|
||||
schemas("resolve_api_url"),
|
||||
schemas("get_runtime_flags"),
|
||||
schemas("set_browser_allow_all"),
|
||||
schemas("workspace_onboarding_flag_exists"),
|
||||
@@ -106,6 +107,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("update_browser_settings"),
|
||||
handler: handle_update_browser_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("resolve_api_url"),
|
||||
handler: handle_resolve_api_url,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_runtime_flags"),
|
||||
handler: handle_get_runtime_flags,
|
||||
@@ -264,6 +269,18 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
inputs: vec![optional_bool("enabled", "Enable browser integration.")],
|
||||
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
|
||||
},
|
||||
"resolve_api_url" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "resolve_api_url",
|
||||
description: "Resolve effective API base URL using config/env/default from core.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "api_url",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Resolved backend API URL.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"get_runtime_flags" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "get_runtime_flags",
|
||||
@@ -412,6 +429,10 @@ fn handle_get_runtime_flags(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(config_rpc::get_runtime_flags()) })
|
||||
}
|
||||
|
||||
fn handle_resolve_api_url(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(config_rpc::load_and_resolve_api_url().await?) })
|
||||
}
|
||||
|
||||
fn handle_set_browser_allow_all(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<SetBrowserAllowAllParams>(params)?;
|
||||
|
||||
@@ -62,20 +62,8 @@ pub(crate) fn resolve_daemon_executable() -> Result<PathBuf> {
|
||||
Ok(exe)
|
||||
}
|
||||
|
||||
pub(crate) fn daemon_program_args(exe: &std::path::Path) -> Vec<String> {
|
||||
let raw_file_name = exe.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
let file_name = raw_file_name.to_ascii_lowercase();
|
||||
let standalone_core_binary = !is_current_executable(exe)
|
||||
&& (file_name.contains("openhuman")
|
||||
|| file_name.starts_with("openhuman-")
|
||||
|| raw_file_name == "openhuman"
|
||||
|| raw_file_name == "openhuman.exe");
|
||||
|
||||
if standalone_core_binary {
|
||||
vec!["serve".to_string()]
|
||||
} else {
|
||||
vec!["core".to_string(), "serve".to_string()]
|
||||
}
|
||||
pub(crate) fn daemon_program_args(_exe: &std::path::Path) -> Vec<String> {
|
||||
vec!["run".to_string()]
|
||||
}
|
||||
|
||||
fn is_current_executable(candidate: &std::path::Path) -> bool {
|
||||
|
||||
@@ -26,6 +26,10 @@ pub struct ServiceStatus {
|
||||
}
|
||||
|
||||
pub fn install(config: &Config) -> Result<ServiceStatus> {
|
||||
if super::mock::is_enabled() {
|
||||
return super::mock::install(config);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::install(config)?;
|
||||
@@ -46,6 +50,10 @@ pub fn install(config: &Config) -> Result<ServiceStatus> {
|
||||
}
|
||||
|
||||
pub fn start(config: &Config) -> Result<ServiceStatus> {
|
||||
if super::mock::is_enabled() {
|
||||
return super::mock::start(config);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::start(config);
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -57,6 +65,10 @@ pub fn start(config: &Config) -> Result<ServiceStatus> {
|
||||
}
|
||||
|
||||
pub fn stop(config: &Config) -> Result<ServiceStatus> {
|
||||
if super::mock::is_enabled() {
|
||||
return super::mock::stop(config);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::stop(config)?;
|
||||
@@ -77,6 +89,10 @@ pub fn stop(config: &Config) -> Result<ServiceStatus> {
|
||||
}
|
||||
|
||||
pub fn status(config: &Config) -> Result<ServiceStatus> {
|
||||
if super::mock::is_enabled() {
|
||||
return super::mock::status(config);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::status(config);
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -88,6 +104,10 @@ pub fn status(config: &Config) -> Result<ServiceStatus> {
|
||||
}
|
||||
|
||||
pub fn uninstall(config: &Config) -> Result<ServiceStatus> {
|
||||
if super::mock::is_enabled() {
|
||||
return super::mock::uninstall(config);
|
||||
}
|
||||
|
||||
let _ = stop(config);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Deterministic, file-backed service manager used by E2E tests.
|
||||
//! Enabled via `OPENHUMAN_SERVICE_MOCK`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
use super::common::SERVICE_LABEL;
|
||||
use super::{ServiceState, ServiceStatus};
|
||||
|
||||
const ENV_SERVICE_MOCK: &str = "OPENHUMAN_SERVICE_MOCK";
|
||||
const ENV_SERVICE_MOCK_STATE_FILE: &str = "OPENHUMAN_SERVICE_MOCK_STATE_FILE";
|
||||
const DEFAULT_STATE_FILE: &str = "service-mock-state.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct MockFailures {
|
||||
install: Option<String>,
|
||||
start: Option<String>,
|
||||
stop: Option<String>,
|
||||
status: Option<String>,
|
||||
uninstall: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct MockServiceState {
|
||||
installed: bool,
|
||||
running: bool,
|
||||
agent_running: bool,
|
||||
failures: MockFailures,
|
||||
}
|
||||
|
||||
impl Default for MockServiceState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
installed: false,
|
||||
running: false,
|
||||
agent_running: true,
|
||||
failures: MockFailures::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_enabled() -> bool {
|
||||
match std::env::var(ENV_SERVICE_MOCK) {
|
||||
Ok(raw) => matches!(
|
||||
raw.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn install(config: &Config) -> Result<ServiceStatus> {
|
||||
log::info!("[service-mock] install requested");
|
||||
let mut state = load_state(config)?;
|
||||
maybe_fail(state.failures.install.as_deref(), "install")?;
|
||||
state.installed = true;
|
||||
state.running = false;
|
||||
save_state(config, &state)?;
|
||||
log::info!("[service-mock] install completed: installed=true running=false");
|
||||
status(config)
|
||||
}
|
||||
|
||||
pub(crate) fn start(config: &Config) -> Result<ServiceStatus> {
|
||||
log::info!("[service-mock] start requested");
|
||||
let mut state = load_state(config)?;
|
||||
maybe_fail(state.failures.start.as_deref(), "start")?;
|
||||
if !state.installed {
|
||||
log::warn!("[service-mock] start requested while not installed");
|
||||
return Ok(service_status_from_state(config, &state));
|
||||
}
|
||||
state.running = true;
|
||||
save_state(config, &state)?;
|
||||
log::info!("[service-mock] start completed: installed=true running=true");
|
||||
status(config)
|
||||
}
|
||||
|
||||
pub(crate) fn stop(config: &Config) -> Result<ServiceStatus> {
|
||||
log::info!("[service-mock] stop requested");
|
||||
let mut state = load_state(config)?;
|
||||
maybe_fail(state.failures.stop.as_deref(), "stop")?;
|
||||
state.running = false;
|
||||
save_state(config, &state)?;
|
||||
log::info!("[service-mock] stop completed: running=false");
|
||||
status(config)
|
||||
}
|
||||
|
||||
pub(crate) fn status(config: &Config) -> Result<ServiceStatus> {
|
||||
let state = load_state(config)?;
|
||||
maybe_fail(state.failures.status.as_deref(), "status")?;
|
||||
log::info!(
|
||||
"[service-mock] status requested: installed={} running={} agent_running={}",
|
||||
state.installed,
|
||||
state.running,
|
||||
state.agent_running
|
||||
);
|
||||
Ok(service_status_from_state(config, &state))
|
||||
}
|
||||
|
||||
pub(crate) fn uninstall(config: &Config) -> Result<ServiceStatus> {
|
||||
log::info!("[service-mock] uninstall requested");
|
||||
let mut state = load_state(config)?;
|
||||
maybe_fail(state.failures.uninstall.as_deref(), "uninstall")?;
|
||||
state.installed = false;
|
||||
state.running = false;
|
||||
save_state(config, &state)?;
|
||||
log::info!("[service-mock] uninstall completed: installed=false running=false");
|
||||
status(config)
|
||||
}
|
||||
|
||||
pub(crate) fn mock_agent_running() -> Option<bool> {
|
||||
if !is_enabled() {
|
||||
return None;
|
||||
}
|
||||
let path = state_file_path_without_config();
|
||||
read_state_from_path(&path)
|
||||
.ok()
|
||||
.map(|state| state.agent_running)
|
||||
}
|
||||
|
||||
fn maybe_fail(message: Option<&str>, operation: &str) -> Result<()> {
|
||||
if let Some(msg) = message {
|
||||
log::error!("[service-mock] forced failure for operation={operation}: {msg}");
|
||||
anyhow::bail!("[service-mock] {operation} failed: {msg}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_state(config: &Config) -> Result<MockServiceState> {
|
||||
let path = state_file_path(config);
|
||||
if !path.exists() {
|
||||
let state = MockServiceState::default();
|
||||
save_state_to_path(&path, &state)?;
|
||||
log::info!(
|
||||
"[service-mock] created default state file at {}",
|
||||
path.display()
|
||||
);
|
||||
return Ok(state);
|
||||
}
|
||||
log::debug!("[service-mock] loading state from {}", path.display());
|
||||
read_state_from_path(&path)
|
||||
}
|
||||
|
||||
fn save_state(config: &Config, state: &MockServiceState) -> Result<()> {
|
||||
save_state_to_path(&state_file_path(config), state)
|
||||
}
|
||||
|
||||
fn save_state_to_path(path: &Path, state: &MockServiceState) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed creating {}", parent.display()))?;
|
||||
}
|
||||
let bytes =
|
||||
serde_json::to_vec_pretty(state).context("failed serializing service mock state")?;
|
||||
std::fs::write(path, bytes)
|
||||
.with_context(|| format!("failed writing service mock state {}", path.display()))?;
|
||||
log::debug!(
|
||||
"[service-mock] wrote state to {} (installed={} running={} agent_running={})",
|
||||
path.display(),
|
||||
state.installed,
|
||||
state.running,
|
||||
state.agent_running
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn state_file_path(config: &Config) -> PathBuf {
|
||||
if let Some(path) = env_state_file() {
|
||||
return path;
|
||||
}
|
||||
|
||||
config
|
||||
.config_path
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(DEFAULT_STATE_FILE)
|
||||
}
|
||||
|
||||
fn state_file_path_without_config() -> PathBuf {
|
||||
if let Some(path) = env_state_file() {
|
||||
return path;
|
||||
}
|
||||
PathBuf::from(DEFAULT_STATE_FILE)
|
||||
}
|
||||
|
||||
fn env_state_file() -> Option<PathBuf> {
|
||||
let path = std::env::var(ENV_SERVICE_MOCK_STATE_FILE).ok()?;
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(trimmed))
|
||||
}
|
||||
|
||||
fn read_state_from_path(path: &Path) -> Result<MockServiceState> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed reading service mock state {}", path.display()))?;
|
||||
let parsed = serde_json::from_str::<MockServiceState>(&raw).with_context(|| {
|
||||
format!(
|
||||
"failed parsing service mock state {} as JSON",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn service_status_from_state(config: &Config, state: &MockServiceState) -> ServiceStatus {
|
||||
ServiceStatus {
|
||||
state: if !state.installed {
|
||||
ServiceState::NotInstalled
|
||||
} else if state.running {
|
||||
ServiceState::Running
|
||||
} else {
|
||||
ServiceState::Stopped
|
||||
},
|
||||
unit_path: mock_unit_path(config),
|
||||
label: mock_label().to_string(),
|
||||
details: Some("service mock backend".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn mock_unit_path(_config: &Config) -> Option<PathBuf> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
Some(
|
||||
PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("LaunchAgents")
|
||||
.join(format!("{SERVICE_LABEL}.plist")),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn mock_unit_path(config: &Config) -> Option<PathBuf> {
|
||||
Some(
|
||||
config
|
||||
.config_path
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".config")
|
||||
.join("systemd")
|
||||
.join("user")
|
||||
.join("openhuman.service"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn mock_unit_path(_config: &Config) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
fn mock_unit_path(_config: &Config) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn mock_label() -> &'static str {
|
||||
SERVICE_LABEL
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn mock_label() -> &'static str {
|
||||
"openhuman.service"
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn mock_label() -> &'static str {
|
||||
"OpenHuman Core"
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
fn mock_label() -> &'static str {
|
||||
SERVICE_LABEL
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod common;
|
||||
mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
pub(crate) mod mock;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
|
||||
+13
-3
@@ -158,8 +158,16 @@ async fn json_rpc_protocol_auth_and_agent_hello() {
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
|
||||
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
|
||||
let mock_origin = format!("http://{}", mock_addr);
|
||||
let external_backend = std::env::var("BACKEND_URL")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("VITE_BACKEND_URL").ok())
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
let (mock_origin, mock_join) = if let Some(origin) = external_backend {
|
||||
(origin, None)
|
||||
} else {
|
||||
let (mock_addr, join) = serve_on_ephemeral(mock_upstream_router()).await;
|
||||
(format!("http://{}", mock_addr), Some(join))
|
||||
};
|
||||
|
||||
write_min_config(&openhuman_home, &mock_origin);
|
||||
|
||||
@@ -219,6 +227,8 @@ async fn json_rpc_protocol_auth_and_agent_hello() {
|
||||
"unexpected agent reply: {reply:?}"
|
||||
);
|
||||
|
||||
mock_join.abort();
|
||||
if let Some(join) = mock_join {
|
||||
join.abort();
|
||||
}
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
@@ -1294,7 +1294,7 @@
|
||||
dependencies:
|
||||
postcss-selector-parser "6.0.10"
|
||||
|
||||
"@tauri-apps/api@^2.10.0":
|
||||
"@tauri-apps/api@2.10.1", "@tauri-apps/api@^2.10.0":
|
||||
version "2.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.10.1.tgz#57c1bae6114ec33d977eb2b50dfefc25fa84fc93"
|
||||
integrity sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==
|
||||
|
||||
Reference in New Issue
Block a user