mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Merge pull request #117 from vezuresdotxyz/develop
chore: merge develop into main (v0.48.0)
This commit is contained in:
@@ -22,6 +22,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
submodules: true
|
||||
token: ${{ secrets.XGH_TOKEN_READ }}
|
||||
|
||||
- name: Setup Node.js 24.x
|
||||
uses: actions/setup-node@v4
|
||||
@@ -70,8 +71,8 @@ jobs:
|
||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
run: yarn build
|
||||
- name: Install skills dependencies
|
||||
run: cd skills && yarn install --frozen-lockfile
|
||||
|
||||
- name: Build Tauri app
|
||||
run: yarn tauri build -- --target x86_64-unknown-linux-gnu
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
# branches:
|
||||
# - main
|
||||
workflow_run:
|
||||
workflows: ["Version Bump"]
|
||||
workflows: ['Version Bump']
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
@@ -171,15 +171,15 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
target: "aarch64-apple-darwin"
|
||||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
target: "x86_64-apple-darwin"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: ""
|
||||
target: ""
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
target: 'aarch64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
target: 'x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
target: ''
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: "yarn"
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -323,7 +323,7 @@ jobs:
|
||||
# macOS 10.15+ required for std::filesystem used by llama.cpp
|
||||
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }}
|
||||
with:
|
||||
args: "-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}"
|
||||
args: '-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}'
|
||||
includeDebug: ${{ needs.get-version.outputs.should-publish == '' && inputs.forceRelease != 'true' }}
|
||||
includeRelease: ${{ needs.get-version.outputs.should-publish != '' || inputs.forceRelease == 'true' }}
|
||||
# TDLib dylibs are now bundled natively via build.rs + tauri.conf.json macOS.frameworks
|
||||
|
||||
@@ -7,11 +7,11 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish to release (requires main branch)"
|
||||
description: 'Publish to release (requires main branch)'
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_run:
|
||||
workflows: ["Version Bump"]
|
||||
workflows: ['Version Bump']
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
@@ -119,13 +119,14 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
submodules: true
|
||||
token: ${{ secrets.XGH_TOKEN_READ }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
|
||||
- name: Setup Node.js 24.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: "yarn"
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -135,8 +136,8 @@ jobs:
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,108 @@
|
||||
# Tauri + React + Typescript
|
||||
<h1 align="center">AlphaHuman</h1>
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
<p align="center">
|
||||
<strong>Your most productive co-worker</strong>
|
||||
</p>
|
||||
|
||||
## Recommended IDE Setup
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/status-early%20beta-orange" alt="Early Beta" />
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux%20%7C%20Android%20%7C%20iOS-blue" alt="Platforms" />
|
||||
<a href="https://github.com/alphahumanxyz/alphahuman/releases/latest"><img src="https://img.shields.io/github/v/release/alphahumanxyz/alphahuman?label=latest" alt="Latest Release" /></a>
|
||||
</p>
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||

|
||||
|
||||
<p align="center" style="font-style: italic">
|
||||
"The Tet. What a brilliant machine" Morgan Freeman as he <a href="https://youtu.be/SveLVpqy_Rc?si=y83aZNokPiUjILN0&t=60">reminisces about the Tet</a> in the movie Oblivion
|
||||
</p>
|
||||
|
||||
## What is AlphaHuman?
|
||||
|
||||
AlphaHuman is a personal AI assistant that helps you manage high-volume communication without reading everything yourself. It connects to your messaging platforms and productivity tools, understands conversations in context, and produces clear, actionable outputs you can use immediately.
|
||||
|
||||
AlphaHuman is not a chatbot, browser extension, or cloud-only service. It is a native application that runs on your device, connects to your tools, and works only when you ask it to. Think of it as a second brain that sits across your communication and productivity stack.
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Summarize conversations** -- Understand what happened without reading everything
|
||||
- **Surface signals** -- Decisions, action items, risks, and sentiment shifts extracted automatically
|
||||
- **Generate responses** -- Context-aware reply suggestions informed by conversation history
|
||||
- **Create workflows** -- Turn unstructured commitments into trackable actions
|
||||
- **Export intelligence** -- Push summaries, action items, and structured data to Notion, Google Sheets, and connected tools
|
||||
- **Run automations** -- Via a sandboxed skills engine that extends the platform without app updates
|
||||
|
||||
## Download
|
||||
|
||||
> **Early Beta** -- AlphaHuman is under active development. Expect rough edges.
|
||||
|
||||
### macOS
|
||||
|
||||
| Chip | Download |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Apple Silicon (M1/M2/M3/M4) | [`.dmg` (aarch64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_aarch64.dmg) |
|
||||
| Intel | [`.dmg` (x64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x64.dmg) |
|
||||
|
||||
### Windows
|
||||
|
||||
| Architecture | Download |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| x64 | [`.msi`](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x64_en-US.msi) |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | Download |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Debian / Ubuntu | [`.deb` (amd64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_amd64.deb) |
|
||||
| Fedora / RHEL | [`.rpm` (x86_64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x86_64.rpm) |
|
||||
| Universal | [`.AppImage`](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_amd64.AppImage) |
|
||||
|
||||
### Mobile
|
||||
|
||||
- **Android** and **iOS** -- Coming soon
|
||||
|
||||
Browse all releases: [github.com/alphahumanxyz/alphahuman/releases](https://github.com/alphahumanxyz/alphahuman/releases)
|
||||
|
||||
## Skills
|
||||
|
||||
AlphaHuman uses a pluggable **skills** architecture. Each skill connects to an external service, syncs relevant data locally, and exposes tools that you (or the AI) can use. Skills run in a sandboxed environment with their own database, storage, and permissions.
|
||||
|
||||
| Skill | Status | Description |
|
||||
| --------------- | ----------- | ------------------------------------------------------------- |
|
||||
| Telegram | Available | Chats, messages, contacts, search, admin tools, AI summaries |
|
||||
| Notion | Available | Pages, databases, blocks, users, comments, search, local sync |
|
||||
| Gmail | In Progress | Email management, labels, search, send/receive with OAuth2 |
|
||||
| Google Calendar | In Progress | Calendars, events, scheduling with OAuth2 |
|
||||
| Google Drive | In Progress | Files, Sheets, Docs with OAuth2 |
|
||||
| Slack | In Progress | Messages, channels, real-time events |
|
||||
| Web3 Wallets | Planned | EVM wallet management, balance checks, network monitoring |
|
||||
|
||||
## Privacy-First
|
||||
|
||||
- **Zero retention** -- Message content is processed to produce output, then discarded
|
||||
- **OS-level credential storage** -- Desktop platforms use native keychains (macOS Keychain, Windows Credential Manager)
|
||||
- **No training on your data** -- Your data is never used for model improvement
|
||||
- **Request-only processing** -- Nothing happens without an explicit user action; no background scanning
|
||||
- **Sandboxed skills** -- Each skill runs in isolation with memory and resource limits
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Download** the installer for your platform from the [releases page](https://github.com/alphahumanxyz/alphahuman/releases/latest)
|
||||
2. **Install** the app (drag to Applications on macOS, or use your package manager on Linux)
|
||||
3. **Connect a source** -- follow the in-app onboarding to link Telegram, Notion, Gmail, or other services
|
||||
4. **Run your first request** -- ask the AI to summarize what you missed, extract action items, or surface key decisions
|
||||
|
||||
## Links
|
||||
|
||||
- [Architecture Overview](./ARCHITECTURE.md) -- How AlphaHuman is built
|
||||
- [Changelog](./CHANGELOG.md) -- Release history
|
||||
- [Website](https://alphahuman.xyz) -- Learn more
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with love by a bunch of Web3 nerds
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>AlphaHuman is in early beta. Features may change, break, or disappear. Use at your own risk.</sub>
|
||||
</p>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 350 KiB |
@@ -205,6 +205,44 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
// E2E test files (Appium/WebDriverIO) — use tsconfig.e2e.json for parsing
|
||||
{
|
||||
files: ['test/e2e/**/*.ts', 'wdio.conf.ts'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.e2e.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
globals: {
|
||||
browser: 'readonly',
|
||||
$: 'readonly',
|
||||
$$: 'readonly',
|
||||
describe: 'readonly',
|
||||
it: 'readonly',
|
||||
before: 'readonly',
|
||||
after: 'readonly',
|
||||
beforeEach: 'readonly',
|
||||
afterEach: 'readonly',
|
||||
expect: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// JavaScript files configuration
|
||||
{
|
||||
files: ['**/*.js', '**/*.jsx'],
|
||||
|
||||
+6
-4
@@ -24,10 +24,11 @@
|
||||
"test:unit:watch": "vitest",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
|
||||
"test:e2e:build": "yarn macos:build:debug",
|
||||
"test:e2e:run": "bash scripts/e2e.sh",
|
||||
"test:e2e": "yarn test:e2e:build && yarn test:e2e:run",
|
||||
"test:rust": "source $HOME/.cargo/env 2>/dev/null; cargo test --manifest-path src-tauri/Cargo.toml",
|
||||
"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": "yarn test:e2e:build && yarn test:e2e:login && yarn test:e2e:auth",
|
||||
"test:all": "yarn test:coverage && yarn test:rust && yarn test:e2e",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
@@ -55,6 +56,7 @@
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run E2E auth & access control tests only.
|
||||
#
|
||||
# Starts Appium, cleans app caches, runs the auth-access-control spec,
|
||||
# then tears everything down. Each flow script is self-contained so
|
||||
# specs don't pollute each other's Redux Persist state.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/e2e-auth.sh
|
||||
# APPIUM_PORT=4723 ./scripts/e2e-auth.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
APPIUM_PORT="${APPIUM_PORT:-4723}"
|
||||
E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}"
|
||||
SPEC="test/e2e/specs/auth-access-control.spec.ts"
|
||||
|
||||
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
|
||||
export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
|
||||
|
||||
# Clean cached app data for a fresh state — Redux Persist would otherwise
|
||||
# remember the JWT from a previous run and skip the login flow.
|
||||
echo "Cleaning cached app data..."
|
||||
rm -rf ~/Library/WebKit/com.alphahuman.app
|
||||
rm -rf ~/Library/Caches/com.alphahuman.app
|
||||
rm -rf "$HOME/Library/Application Support/com.alphahuman.app"
|
||||
|
||||
# Verify the frontend dist has the mock server URL baked in.
|
||||
DIST_JS="$(ls dist/assets/index-*.js 2>/dev/null | head -1)"
|
||||
if [ -z "$DIST_JS" ]; then
|
||||
echo "ERROR: No frontend bundle found at dist/assets/index-*.js." >&2
|
||||
echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then
|
||||
echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2
|
||||
echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified: frontend bundle contains mock server URL."
|
||||
|
||||
# --- Resolve Node 24 via nvm ---------------------------------------------------
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
|
||||
NODE24="$(nvm which 24 2>/dev/null || true)"
|
||||
if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then
|
||||
echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APPIUM_BIN="$(dirname "$NODE24")/appium"
|
||||
if [ ! -x "$APPIUM_BIN" ]; then
|
||||
echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Start Appium in the background -------------------------------------------
|
||||
APPIUM_LOG="/tmp/appium-e2e-auth.log"
|
||||
NODE_VER=$("$NODE24" --version)
|
||||
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
|
||||
echo " Appium logs: $APPIUM_LOG"
|
||||
"$NODE24" "$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
|
||||
|
||||
# Wait for Appium to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then
|
||||
echo "Appium is ready."
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "ERROR: Appium did not start within 30 seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# --- Run WebDriverIO ----------------------------------------------------------
|
||||
echo "Running E2E auth flow tests ($SPEC)..."
|
||||
npx wdio run wdio.conf.ts --spec "$SPEC"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/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/).
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Source Cargo environment
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
|
||||
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"
|
||||
|
||||
# Clean Rust build cache so frontend assets get re-embedded
|
||||
cargo clean --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
# Load .env for other vars (Telegram API keys, etc.)
|
||||
source scripts/load-dotenv.sh
|
||||
|
||||
# Re-export mock URL in case load-dotenv.sh clobbered it
|
||||
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}"
|
||||
|
||||
# Build .app only (skip DMG to avoid bundle_dmg.sh failures)
|
||||
tauri build --bundles app --debug
|
||||
|
||||
echo "E2E build complete."
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run E2E login flow tests only.
|
||||
#
|
||||
# Starts Appium, cleans app caches, runs the login-flow spec,
|
||||
# then tears everything down. Each flow script is self-contained so
|
||||
# specs don't pollute each other's Redux Persist state.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/e2e-login.sh
|
||||
# APPIUM_PORT=4723 ./scripts/e2e-login.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
APPIUM_PORT="${APPIUM_PORT:-4723}"
|
||||
E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}"
|
||||
SPEC="test/e2e/specs/login-flow.spec.ts"
|
||||
|
||||
# Point the app at the local mock server (baked into the build already, but
|
||||
# also exported here so the Rust backend / any env-based config picks it up).
|
||||
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
|
||||
export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
|
||||
|
||||
# Clean cached app data for a fresh state — Redux Persist would otherwise
|
||||
# remember the JWT from a previous run and skip the login flow.
|
||||
echo "Cleaning cached app data..."
|
||||
rm -rf ~/Library/WebKit/com.alphahuman.app
|
||||
rm -rf ~/Library/Caches/com.alphahuman.app
|
||||
rm -rf "$HOME/Library/Application Support/com.alphahuman.app"
|
||||
|
||||
# Verify the frontend dist has the mock server URL baked in (not production).
|
||||
# Tauri compresses assets when embedding, so we check dist/ not the binary.
|
||||
DIST_JS="$(ls dist/assets/index-*.js 2>/dev/null | head -1)"
|
||||
if [ -z "$DIST_JS" ]; then
|
||||
echo "ERROR: No frontend bundle found at dist/assets/index-*.js." >&2
|
||||
echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then
|
||||
echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2
|
||||
echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified: frontend bundle contains mock server URL."
|
||||
|
||||
# --- Resolve Node 24 via nvm ---------------------------------------------------
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
|
||||
NODE24="$(nvm which 24 2>/dev/null || true)"
|
||||
if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then
|
||||
echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APPIUM_BIN="$(dirname "$NODE24")/appium"
|
||||
if [ ! -x "$APPIUM_BIN" ]; then
|
||||
echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Start Appium in the background -------------------------------------------
|
||||
APPIUM_LOG="/tmp/appium-e2e-login.log"
|
||||
NODE_VER=$("$NODE24" --version)
|
||||
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
|
||||
echo " Appium logs: $APPIUM_LOG"
|
||||
"$NODE24" "$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
|
||||
|
||||
# Wait for Appium to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then
|
||||
echo "Appium is ready."
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "ERROR: Appium did not start within 30 seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# --- Run WebDriverIO ----------------------------------------------------------
|
||||
echo "Running E2E login flow tests ($SPEC)..."
|
||||
npx wdio run wdio.conf.ts --spec "$SPEC"
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Start Appium under Node 24 (required by appium v3), run WebDriverIO E2E
|
||||
# tests, then tear everything down.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/e2e.sh # run tests
|
||||
# APPIUM_PORT=4723 ./scripts/e2e.sh # custom port
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
APPIUM_PORT="${APPIUM_PORT:-4723}"
|
||||
|
||||
# --- Resolve Node 24 via nvm ---------------------------------------------------
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
# shellcheck source=/dev/null
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
|
||||
NODE24="$(nvm which 24 2>/dev/null || true)"
|
||||
if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then
|
||||
echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APPIUM_BIN="$(dirname "$NODE24")/appium"
|
||||
if [ ! -x "$APPIUM_BIN" ]; then
|
||||
echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Start Appium in the background -------------------------------------------
|
||||
NODE_VER=$("$NODE24" --version)
|
||||
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
|
||||
"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security &
|
||||
APPIUM_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "Stopping Appium (pid $APPIUM_PID)..."
|
||||
kill "$APPIUM_PID" 2>/dev/null || true
|
||||
wait "$APPIUM_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Wait for Appium to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then
|
||||
echo "Appium is ready."
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "ERROR: Appium did not start within 30 seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# --- Run WebDriverIO ----------------------------------------------------------
|
||||
echo "Running E2E tests..."
|
||||
npx wdio run wdio.conf.ts
|
||||
+1
-1
Submodule skills updated: 272640010a...0c6a11b1e0
@@ -108,6 +108,14 @@ const AppRoutes = () => {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/conversations/:threadId"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<Conversations />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Invites */}
|
||||
<Route
|
||||
|
||||
@@ -18,21 +18,21 @@ const navItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// id: 'conversations',
|
||||
// label: 'Conversations',
|
||||
// path: '/conversations',
|
||||
// icon: (
|
||||
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
// <path
|
||||
// strokeLinecap="round"
|
||||
// strokeLinejoin="round"
|
||||
// strokeWidth={2}
|
||||
// d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
// />
|
||||
// </svg>
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
id: 'conversations',
|
||||
label: 'Conversations',
|
||||
path: '/conversations',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'intelligence',
|
||||
label: 'Intelligence',
|
||||
@@ -91,6 +91,17 @@ const MiniSidebar = () => {
|
||||
const navigate = useNavigate();
|
||||
const token = useAppSelector(state => state.auth.token);
|
||||
|
||||
// Unread count for Conversations: threads with lastMessageAt > lastViewedAt (must be before early return)
|
||||
const conversationsUnreadCount = useAppSelector(state => {
|
||||
const { threads, lastViewedAt } = state.thread;
|
||||
if (threads.length === 0) return 0;
|
||||
return threads.filter(t => {
|
||||
const viewed = lastViewedAt[t.id];
|
||||
const lastMsg = new Date(t.lastMessageAt || t.createdAt).getTime();
|
||||
return viewed == null || lastMsg > viewed;
|
||||
}).length;
|
||||
});
|
||||
|
||||
// Hide sidebar when not authenticated or on public/onboarding routes
|
||||
const hiddenPaths = ['/', '/login', '/onboarding'];
|
||||
if (!token || hiddenPaths.includes(location.pathname)) {
|
||||
@@ -107,6 +118,7 @@ const MiniSidebar = () => {
|
||||
<div className="w-14 flex-shrink-0 bg-black backdrop-blur-md border-r border-white/10 flex flex-col items-center py-4 gap-2 z-50 relative">
|
||||
{navItems.map(item => {
|
||||
const active = isActive(item.path);
|
||||
const showUnreadBadge = item.id === 'conversations' && conversationsUnreadCount > 0;
|
||||
return (
|
||||
<div key={item.id} className="relative group">
|
||||
<button
|
||||
@@ -119,6 +131,13 @@ const MiniSidebar = () => {
|
||||
aria-label={item.label}>
|
||||
{item.icon}
|
||||
</button>
|
||||
{showUnreadBadge && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-primary-500 text-white text-[10px] font-medium"
|
||||
aria-label={`${conversationsUnreadCount} unread`}>
|
||||
{conversationsUnreadCount > 99 ? '99+' : conversationsUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Tooltip - appears to the right */}
|
||||
<div className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 bg-stone-800 text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
{item.label}
|
||||
|
||||
@@ -6,15 +6,15 @@ import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/
|
||||
import type { SkillConnectionStatus } from '../lib/skills/types';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import SkillSetupModal from './skills/SkillSetupModal';
|
||||
import {
|
||||
DefaultIcon,
|
||||
SKILL_ICONS,
|
||||
SkillActionButton,
|
||||
type SkillListEntry,
|
||||
STATUS_DISPLAY,
|
||||
STATUS_PRIORITY,
|
||||
type SkillListEntry,
|
||||
} from './skills/shared';
|
||||
import SkillSetupModal from './skills/SkillSetupModal';
|
||||
|
||||
interface SkillRowProps {
|
||||
skillId: string;
|
||||
|
||||
@@ -1,49 +1,41 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ConnectionIndicator from "../ConnectionIndicator";
|
||||
import { renderWithProviders } from "../../test/test-utils";
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe("ConnectionIndicator", () => {
|
||||
it("renders connected state with override prop", () => {
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import ConnectionIndicator from '../ConnectionIndicator';
|
||||
|
||||
describe('ConnectionIndicator', () => {
|
||||
it('renders connected state with override prop', () => {
|
||||
renderWithProviders(<ConnectionIndicator status="connected" />);
|
||||
expect(
|
||||
screen.getByText(/Connected to AlphaHuman AI/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Connected to AlphaHuman AI/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disconnected state", () => {
|
||||
it('renders disconnected state', () => {
|
||||
renderWithProviders(<ConnectionIndicator status="disconnected" />);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders connecting state", () => {
|
||||
it('renders connecting state', () => {
|
||||
renderWithProviders(<ConnectionIndicator status="connecting" />);
|
||||
expect(screen.getByText("Connecting")).toBeInTheDocument();
|
||||
expect(screen.getByText('Connecting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description text when provided", () => {
|
||||
it('renders description text when provided', () => {
|
||||
renderWithProviders(
|
||||
<ConnectionIndicator
|
||||
status="connected"
|
||||
description="Custom description"
|
||||
/>
|
||||
<ConnectionIndicator status="connected" description="Custom description" />
|
||||
);
|
||||
expect(screen.getByText("Custom description")).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render description when empty string", () => {
|
||||
renderWithProviders(
|
||||
<ConnectionIndicator status="connected" description="" />
|
||||
);
|
||||
it('does not render description when empty string', () => {
|
||||
renderWithProviders(<ConnectionIndicator status="connected" description="" />);
|
||||
// Default description should not appear
|
||||
expect(
|
||||
screen.queryByText(/Keep the app running/)
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Keep the app running/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to store socket status when no override", () => {
|
||||
it('falls back to store socket status when no override', () => {
|
||||
// Default store state has no socket connection → disconnected
|
||||
renderWithProviders(<ConnectionIndicator />);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import ProtectedRoute from "../ProtectedRoute";
|
||||
import { renderWithProviders } from "../../test/test-utils";
|
||||
import { screen } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe("ProtectedRoute", () => {
|
||||
it("renders children when token exists", () => {
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import ProtectedRoute from '../ProtectedRoute';
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
it('renders children when token exists', () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -19,19 +20,15 @@ describe("ProtectedRoute", () => {
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: "valid-jwt",
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
auth: { token: 'valid-jwt', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Protected Content")).toBeInTheDocument();
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to / when no token and requireAuth=true", () => {
|
||||
it('redirects to / when no token and requireAuth=true', () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -45,22 +42,18 @@ describe("ProtectedRoute", () => {
|
||||
<Route path="/" element={<div>Landing</div>} />
|
||||
</Routes>,
|
||||
{
|
||||
initialEntries: ["/dashboard"],
|
||||
initialEntries: ['/dashboard'],
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: null,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Dashboard")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Landing")).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Landing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to custom redirectTo when no token", () => {
|
||||
it('redirects to custom redirectTo when no token', () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -74,21 +67,17 @@ describe("ProtectedRoute", () => {
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>,
|
||||
{
|
||||
initialEntries: ["/dashboard"],
|
||||
initialEntries: ['/dashboard'],
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: null,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Login Page")).toBeInTheDocument();
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to /onboarding when requireOnboarded but not onboarded", () => {
|
||||
it('redirects to /onboarding when requireOnboarded but not onboarded', () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -102,18 +91,14 @@ describe("ProtectedRoute", () => {
|
||||
<Route path="/onboarding" element={<div>Onboarding</div>} />
|
||||
</Routes>,
|
||||
{
|
||||
initialEntries: ["/home"],
|
||||
initialEntries: ['/home'],
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: "valid-jwt",
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
user: { user: { _id: "u1" }, isLoading: false, error: null },
|
||||
auth: { token: 'valid-jwt', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
user: { user: { _id: 'u1' }, isLoading: false, error: null },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Onboarding")).toBeInTheDocument();
|
||||
expect(screen.getByText('Onboarding')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import PublicRoute from "../PublicRoute";
|
||||
import { renderWithProviders } from "../../test/test-utils";
|
||||
import { screen } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe("PublicRoute", () => {
|
||||
it("renders children when user is not authenticated", () => {
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import PublicRoute from '../PublicRoute';
|
||||
|
||||
describe('PublicRoute', () => {
|
||||
it('renders children when user is not authenticated', () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -19,19 +20,15 @@ describe("PublicRoute", () => {
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: null,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Welcome Page")).toBeInTheDocument();
|
||||
expect(screen.getByText('Welcome Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to /home when user is authenticated", () => {
|
||||
it('redirects to /home when user is authenticated', () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -46,20 +43,16 @@ describe("PublicRoute", () => {
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: "jwt-token",
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
auth: { token: 'jwt-token', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Welcome Page")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Home")).toBeInTheDocument();
|
||||
expect(screen.queryByText('Welcome Page')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to custom path when authenticated", () => {
|
||||
it('redirects to custom path when authenticated', () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -74,15 +67,11 @@ describe("PublicRoute", () => {
|
||||
</Routes>,
|
||||
{
|
||||
preloadedState: {
|
||||
auth: {
|
||||
token: "jwt-token",
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
auth: { token: 'jwt-token', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
navigate('/home');
|
||||
} else if (currentRoute === 'team-members' || currentRoute === 'team-invites') {
|
||||
// Check if we're in team management context (has teamId in URL)
|
||||
const teamManageMatch = location.pathname.match(/\/team\/manage\/([^\/]+)/);
|
||||
const teamManageMatch = location.pathname.match(/\/team\/manage\/([^/]+)/);
|
||||
if (teamManageMatch) {
|
||||
const teamId = teamManageMatch[1];
|
||||
navigate(`/settings/team/manage/${teamId}`);
|
||||
|
||||
@@ -105,13 +105,5 @@ export function useIntelligenceStats(): IntelligenceStats {
|
||||
fetchStats();
|
||||
}, [fetchStats, aiStatus]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
memoryFiles,
|
||||
entities,
|
||||
entityError,
|
||||
aiStatus,
|
||||
isLoading,
|
||||
refetch: fetchStats,
|
||||
};
|
||||
return { sessions, memoryFiles, entities, entityError, aiStatus, isLoading, refetch: fetchStats };
|
||||
}
|
||||
|
||||
@@ -259,6 +259,15 @@
|
||||
background-color: rgb(115 115 115);
|
||||
}
|
||||
|
||||
/* Hide scrollbar while keeping scroll behavior */
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Text selection styling */
|
||||
.select-primary::selection {
|
||||
@apply bg-primary-600 text-white;
|
||||
|
||||
+688
-288
File diff suppressed because it is too large
Load Diff
@@ -1,64 +1,52 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { server } from "../../../test/server";
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { server } from '../../../test/server';
|
||||
|
||||
// Mock the store import that apiClient depends on
|
||||
vi.mock("../../../store", () => ({
|
||||
store: {
|
||||
getState: () => ({
|
||||
auth: { token: "test-jwt-token" },
|
||||
}),
|
||||
},
|
||||
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('../../../utils/config', () => ({ BACKEND_URL: 'http://localhost:5005', IS_DEV: true }));
|
||||
|
||||
// Import after mocks
|
||||
const { userApi } = await import("../userApi");
|
||||
const { userApi } = await import('../userApi');
|
||||
|
||||
describe("userApi.getMe", () => {
|
||||
it("returns user data on success", async () => {
|
||||
describe('userApi.getMe', () => {
|
||||
it('returns user data on success', async () => {
|
||||
// Default handler from handlers.ts already handles this
|
||||
const user = await userApi.getMe();
|
||||
expect(user._id).toBe("user-123");
|
||||
expect(user.firstName).toBe("Test");
|
||||
expect(user.username).toBe("testuser");
|
||||
expect(user.subscription.plan).toBe("FREE");
|
||||
expect(user._id).toBe('user-123');
|
||||
expect(user.firstName).toBe('Test');
|
||||
expect(user.username).toBe('testuser');
|
||||
expect(user.subscription.plan).toBe('FREE');
|
||||
});
|
||||
|
||||
it("throws when API returns error response", async () => {
|
||||
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 }
|
||||
);
|
||||
http.get('http://localhost:5005/telegram/me', () => {
|
||||
return HttpResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
})
|
||||
);
|
||||
|
||||
await expect(userApi.getMe()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws when API returns success=false", async () => {
|
||||
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",
|
||||
});
|
||||
http.get('http://localhost:5005/telegram/me', () => {
|
||||
return HttpResponse.json({ success: false, error: 'Invalid token' });
|
||||
})
|
||||
);
|
||||
|
||||
await expect(userApi.getMe()).rejects.toThrow("Invalid token");
|
||||
await expect(userApi.getMe()).rejects.toThrow('Invalid token');
|
||||
});
|
||||
|
||||
it("throws on network error", async () => {
|
||||
it('throws on network error', async () => {
|
||||
server.use(
|
||||
http.get("http://localhost:5005/telegram/me", () => {
|
||||
http.get('http://localhost:5005/telegram/me', () => {
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { ApiResponse } from '../../types/api';
|
||||
import type {
|
||||
PurgeRequestBody,
|
||||
PurgeResultData,
|
||||
SendMessageData,
|
||||
SendMessageResponseData,
|
||||
SuggestQuestionsData,
|
||||
ThreadCreateData,
|
||||
ThreadDeleteData,
|
||||
ThreadMessagesData,
|
||||
ThreadsListData,
|
||||
} from '../../types/thread';
|
||||
@@ -33,15 +35,33 @@ export const threadApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /chat/autocomplete — send a user message to a thread and get the agent response */
|
||||
sendMessage: async (message: string, conversationId: string): Promise<SendMessageData> => {
|
||||
const response = await apiClient.post<ApiResponse<SendMessageData>>('/chat/autocomplete', {
|
||||
/** DELETE /telegram/threads/:threadId — delete a single thread */
|
||||
deleteThread: async (threadId: string): Promise<ThreadDeleteData> => {
|
||||
const response = await apiClient.delete<ApiResponse<ThreadDeleteData>>(
|
||||
`/telegram/threads/${encodeURIComponent(threadId)}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /chat/sendMessage — send a user message to a thread and get the agent response */
|
||||
sendMessage: async (message: string, conversationId: string): Promise<SendMessageResponseData> => {
|
||||
const response = await apiClient.post<ApiResponse<SendMessageResponseData>>('/chat/sendMessage', {
|
||||
message,
|
||||
conversationId,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /chat/autocomplete — suggested starter questions (e.g. for a new/empty thread) */
|
||||
getSuggestQuestions: async (conversationId?: string): Promise<SuggestQuestionsData> => {
|
||||
const url =
|
||||
conversationId != null
|
||||
? `/chat/autocomplete?conversationId=${encodeURIComponent(conversationId)}`
|
||||
: '/chat/autocomplete';
|
||||
const response = await apiClient.get<ApiResponse<SuggestQuestionsData>>(url);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /telegram/purge — purge messages and/or threads */
|
||||
purge: async (body: PurgeRequestBody): Promise<PurgeResultData> => {
|
||||
const response = await apiClient.post<ApiResponse<PurgeResultData>>('/telegram/purge', body);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectIsOnboarded } from "../authSelectors";
|
||||
import type { RootState } from "../index";
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { selectIsOnboarded } from '../authSelectors';
|
||||
import type { RootState } from '../index';
|
||||
|
||||
function makeState(
|
||||
overrides: Partial<{
|
||||
@@ -9,56 +10,40 @@ function makeState(
|
||||
isOnboardedByUser: Record<string, boolean>;
|
||||
}> = {}
|
||||
): RootState {
|
||||
const {
|
||||
token = null,
|
||||
userId,
|
||||
isOnboardedByUser = {},
|
||||
} = overrides;
|
||||
const { token = null, userId, isOnboardedByUser = {} } = overrides;
|
||||
|
||||
return {
|
||||
auth: {
|
||||
token,
|
||||
isOnboardedByUser,
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
auth: { token, isOnboardedByUser, isAnalyticsEnabledByUser: {} },
|
||||
user: {
|
||||
user: userId
|
||||
? ({ _id: userId } as RootState["user"]["user"])
|
||||
: null,
|
||||
user: userId ? ({ _id: userId } as RootState['user']['user']) : null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
socket: { byUser: {} },
|
||||
team: {} as RootState["team"],
|
||||
ai: {} as RootState["ai"],
|
||||
skills: {} as RootState["skills"],
|
||||
team: {} as RootState['team'],
|
||||
ai: {} as RootState['ai'],
|
||||
skills: {} as RootState['skills'],
|
||||
} as RootState;
|
||||
}
|
||||
|
||||
describe("selectIsOnboarded", () => {
|
||||
it("returns false when no user is loaded", () => {
|
||||
describe('selectIsOnboarded', () => {
|
||||
it('returns false when no user is loaded', () => {
|
||||
const state = makeState();
|
||||
expect(selectIsOnboarded(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user exists but is not onboarded", () => {
|
||||
const state = makeState({ userId: "u1" });
|
||||
it('returns false when user exists but is not onboarded', () => {
|
||||
const state = makeState({ userId: 'u1' });
|
||||
expect(selectIsOnboarded(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when user is onboarded", () => {
|
||||
const state = makeState({
|
||||
userId: "u1",
|
||||
isOnboardedByUser: { u1: true },
|
||||
});
|
||||
it('returns true when user is onboarded', () => {
|
||||
const state = makeState({ userId: 'u1', isOnboardedByUser: { u1: true } });
|
||||
expect(selectIsOnboarded(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a different user id", () => {
|
||||
const state = makeState({
|
||||
userId: "u2",
|
||||
isOnboardedByUser: { u1: true },
|
||||
});
|
||||
it('returns false for a different user id', () => {
|
||||
const state = makeState({ userId: 'u2', isOnboardedByUser: { u1: true } });
|
||||
expect(selectIsOnboarded(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import authReducer, {
|
||||
setAnalyticsForUser,
|
||||
setOnboardedForUser,
|
||||
setToken,
|
||||
} from "../authSlice";
|
||||
import teamReducer from "../teamSlice";
|
||||
import userReducer from "../userSlice";
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import authReducer, { setAnalyticsForUser, setOnboardedForUser, setToken } from '../authSlice';
|
||||
import teamReducer from '../teamSlice';
|
||||
import userReducer from '../userSlice';
|
||||
|
||||
function createStore(preloaded?: Record<string, unknown>) {
|
||||
return configureStore({
|
||||
// Cast reducer map to any to avoid strict typing issues in this lightweight test harness.
|
||||
reducer: ({ auth: authReducer, user: userReducer, team: teamReducer } as unknown) as any,
|
||||
reducer: { auth: authReducer, user: userReducer, team: teamReducer } as unknown as any,
|
||||
preloadedState: preloaded as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe("authSlice", () => {
|
||||
it("has correct initial state", () => {
|
||||
describe('authSlice', () => {
|
||||
it('has correct initial state', () => {
|
||||
const store = createStore();
|
||||
const state = store.getState().auth;
|
||||
expect(state.token).toBeNull();
|
||||
@@ -25,29 +22,29 @@ describe("authSlice", () => {
|
||||
expect(state.isAnalyticsEnabledByUser).toEqual({});
|
||||
});
|
||||
|
||||
it("sets token via setToken", () => {
|
||||
it('sets token via setToken', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setToken("jwt-abc-123"));
|
||||
expect(store.getState().auth.token).toBe("jwt-abc-123");
|
||||
store.dispatch(setToken('jwt-abc-123'));
|
||||
expect(store.getState().auth.token).toBe('jwt-abc-123');
|
||||
});
|
||||
|
||||
it("sets onboarded flag per user", () => {
|
||||
it('sets onboarded flag per user', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setOnboardedForUser({ userId: "u1", value: true }));
|
||||
store.dispatch(setOnboardedForUser({ userId: 'u1', value: true }));
|
||||
expect(store.getState().auth.isOnboardedByUser.u1).toBe(true);
|
||||
|
||||
store.dispatch(setOnboardedForUser({ userId: "u2", value: false }));
|
||||
store.dispatch(setOnboardedForUser({ userId: 'u2', value: false }));
|
||||
expect(store.getState().auth.isOnboardedByUser.u2).toBe(false);
|
||||
// u1 should be unaffected
|
||||
expect(store.getState().auth.isOnboardedByUser.u1).toBe(true);
|
||||
});
|
||||
|
||||
it("sets analytics consent per user", () => {
|
||||
it('sets analytics consent per user', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setAnalyticsForUser({ userId: "u1", enabled: true }));
|
||||
store.dispatch(setAnalyticsForUser({ userId: 'u1', enabled: true }));
|
||||
expect(store.getState().auth.isAnalyticsEnabledByUser.u1).toBe(true);
|
||||
|
||||
store.dispatch(setAnalyticsForUser({ userId: "u1", enabled: false }));
|
||||
store.dispatch(setAnalyticsForUser({ userId: 'u1', enabled: false }));
|
||||
expect(store.getState().auth.isAnalyticsEnabledByUser.u1).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectSocketId, selectSocketStatus } from "../socketSelectors";
|
||||
import type { RootState } from "../index";
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RootState } from '../index';
|
||||
import { selectSocketId, selectSocketStatus } from '../socketSelectors';
|
||||
|
||||
// Create a mock JWT with payload { tgUserId: "tg-user-1" }
|
||||
function encodeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const body = btoa(JSON.stringify(payload));
|
||||
return `${header}.${body}.signature`;
|
||||
}
|
||||
@@ -14,58 +15,48 @@ function makeState(
|
||||
byUser: Record<string, { status: string; socketId: string | null }> = {}
|
||||
): RootState {
|
||||
return {
|
||||
auth: {
|
||||
token,
|
||||
isOnboardedByUser: {},
|
||||
isAnalyticsEnabledByUser: {},
|
||||
},
|
||||
auth: { token, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
|
||||
socket: { byUser },
|
||||
user: { user: null, isLoading: false, error: null },
|
||||
team: {} as RootState["team"],
|
||||
ai: {} as RootState["ai"],
|
||||
skills: {} as RootState["skills"],
|
||||
team: {} as RootState['team'],
|
||||
ai: {} as RootState['ai'],
|
||||
skills: {} as RootState['skills'],
|
||||
} as RootState;
|
||||
}
|
||||
|
||||
describe("selectSocketStatus", () => {
|
||||
it("returns disconnected when no token", () => {
|
||||
describe('selectSocketStatus', () => {
|
||||
it('returns disconnected when no token', () => {
|
||||
const state = makeState(null);
|
||||
expect(selectSocketStatus(state)).toBe("disconnected");
|
||||
expect(selectSocketStatus(state)).toBe('disconnected');
|
||||
});
|
||||
|
||||
it("returns status from user state based on JWT tgUserId", () => {
|
||||
const token = encodeJwt({ tgUserId: "tg123" });
|
||||
const state = makeState(token, {
|
||||
tg123: { status: "connected", socketId: "sock-1" },
|
||||
});
|
||||
expect(selectSocketStatus(state)).toBe("connected");
|
||||
it('returns status from user state based on JWT tgUserId', () => {
|
||||
const token = encodeJwt({ tgUserId: 'tg123' });
|
||||
const state = makeState(token, { tg123: { status: 'connected', socketId: 'sock-1' } });
|
||||
expect(selectSocketStatus(state)).toBe('connected');
|
||||
});
|
||||
|
||||
it("returns disconnected when JWT user has no socket state", () => {
|
||||
const token = encodeJwt({ tgUserId: "tg123" });
|
||||
it('returns disconnected when JWT user has no socket state', () => {
|
||||
const token = encodeJwt({ tgUserId: 'tg123' });
|
||||
const state = makeState(token, {});
|
||||
expect(selectSocketStatus(state)).toBe("disconnected");
|
||||
expect(selectSocketStatus(state)).toBe('disconnected');
|
||||
});
|
||||
|
||||
it("uses __pending__ for invalid JWT", () => {
|
||||
const state = makeState("not-a-jwt", {
|
||||
__pending__: { status: "connecting", socketId: null },
|
||||
});
|
||||
expect(selectSocketStatus(state)).toBe("connecting");
|
||||
it('uses __pending__ for invalid JWT', () => {
|
||||
const state = makeState('not-a-jwt', { __pending__: { status: 'connecting', socketId: null } });
|
||||
expect(selectSocketStatus(state)).toBe('connecting');
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectSocketId", () => {
|
||||
it("returns null when no token", () => {
|
||||
describe('selectSocketId', () => {
|
||||
it('returns null when no token', () => {
|
||||
const state = makeState(null);
|
||||
expect(selectSocketId(state)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns socketId from user state", () => {
|
||||
const token = encodeJwt({ tgUserId: "tg123" });
|
||||
const state = makeState(token, {
|
||||
tg123: { status: "connected", socketId: "sock-abc" },
|
||||
});
|
||||
expect(selectSocketId(state)).toBe("sock-abc");
|
||||
it('returns socketId from user state', () => {
|
||||
const token = encodeJwt({ tgUserId: 'tg123' });
|
||||
const state = makeState(token, { tg123: { status: 'connected', socketId: 'sock-abc' } });
|
||||
expect(selectSocketId(state)).toBe('sock-abc');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,78 +1,57 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import socketReducer, {
|
||||
resetForUser,
|
||||
setSocketIdForUser,
|
||||
setStatusForUser,
|
||||
} from "../socketSlice";
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import socketReducer, { resetForUser, setSocketIdForUser, setStatusForUser } from '../socketSlice';
|
||||
|
||||
function createStore() {
|
||||
return configureStore({ reducer: { socket: socketReducer } });
|
||||
}
|
||||
|
||||
describe("socketSlice", () => {
|
||||
it("starts with empty byUser map", () => {
|
||||
describe('socketSlice', () => {
|
||||
it('starts with empty byUser map', () => {
|
||||
const store = createStore();
|
||||
expect(store.getState().socket.byUser).toEqual({});
|
||||
});
|
||||
|
||||
it("sets status for a user", () => {
|
||||
it('sets status for a user', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "connecting" })
|
||||
);
|
||||
expect(store.getState().socket.byUser.u1.status).toBe("connecting");
|
||||
store.dispatch(setStatusForUser({ userId: 'u1', status: 'connecting' }));
|
||||
expect(store.getState().socket.byUser.u1.status).toBe('connecting');
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBeNull();
|
||||
});
|
||||
|
||||
it("clears socketId when status is disconnected", () => {
|
||||
it('clears socketId when status is disconnected', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "connected" })
|
||||
);
|
||||
store.dispatch(
|
||||
setSocketIdForUser({ userId: "u1", socketId: "sock-123" })
|
||||
);
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBe("sock-123");
|
||||
store.dispatch(setStatusForUser({ userId: 'u1', status: 'connected' }));
|
||||
store.dispatch(setSocketIdForUser({ userId: 'u1', socketId: 'sock-123' }));
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBe('sock-123');
|
||||
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "disconnected" })
|
||||
);
|
||||
store.dispatch(setStatusForUser({ userId: 'u1', status: 'disconnected' }));
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBeNull();
|
||||
});
|
||||
|
||||
it("sets socketId for a user", () => {
|
||||
it('sets socketId for a user', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setSocketIdForUser({ userId: "u1", socketId: "sock-abc" })
|
||||
);
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBe("sock-abc");
|
||||
store.dispatch(setSocketIdForUser({ userId: 'u1', socketId: 'sock-abc' }));
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBe('sock-abc');
|
||||
});
|
||||
|
||||
it("resets user state", () => {
|
||||
it('resets user state', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "connected" })
|
||||
);
|
||||
store.dispatch(
|
||||
setSocketIdForUser({ userId: "u1", socketId: "sock-123" })
|
||||
);
|
||||
store.dispatch(resetForUser({ userId: "u1" }));
|
||||
store.dispatch(setStatusForUser({ userId: 'u1', status: 'connected' }));
|
||||
store.dispatch(setSocketIdForUser({ userId: 'u1', socketId: 'sock-123' }));
|
||||
store.dispatch(resetForUser({ userId: 'u1' }));
|
||||
|
||||
expect(store.getState().socket.byUser.u1.status).toBe("disconnected");
|
||||
expect(store.getState().socket.byUser.u1.status).toBe('disconnected');
|
||||
expect(store.getState().socket.byUser.u1.socketId).toBeNull();
|
||||
});
|
||||
|
||||
it("handles multiple users independently", () => {
|
||||
it('handles multiple users independently', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u1", status: "connected" })
|
||||
);
|
||||
store.dispatch(
|
||||
setStatusForUser({ userId: "u2", status: "connecting" })
|
||||
);
|
||||
store.dispatch(setStatusForUser({ userId: 'u1', status: 'connected' }));
|
||||
store.dispatch(setStatusForUser({ userId: 'u2', status: 'connecting' }));
|
||||
|
||||
expect(store.getState().socket.byUser.u1.status).toBe("connected");
|
||||
expect(store.getState().socket.byUser.u2.status).toBe("connecting");
|
||||
expect(store.getState().socket.byUser.u1.status).toBe('connected');
|
||||
expect(store.getState().socket.byUser.u2.status).toBe('connecting');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,58 +1,54 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import userReducer, { clearUser, setUser } from "../userSlice";
|
||||
import type { User } from "../../types/api";
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { User } from '../../types/api';
|
||||
import userReducer, { clearUser, setUser } from '../userSlice';
|
||||
|
||||
function createStore() {
|
||||
return configureStore({ reducer: { user: userReducer } });
|
||||
}
|
||||
|
||||
const mockUser: User = {
|
||||
_id: "user-123",
|
||||
_id: 'user-123',
|
||||
telegramId: 12345678,
|
||||
hasAccess: true,
|
||||
magicWord: "alpha",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
username: "testuser",
|
||||
role: "user",
|
||||
activeTeamId: "team-1",
|
||||
magicWord: 'alpha',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
username: 'testuser',
|
||||
role: 'user',
|
||||
activeTeamId: 'team-1',
|
||||
referral: {},
|
||||
subscription: { hasActiveSubscription: false, plan: "FREE" },
|
||||
subscription: { hasActiveSubscription: false, plan: 'FREE' },
|
||||
settings: {
|
||||
dailySummariesEnabled: false,
|
||||
dailySummaryChatIds: [],
|
||||
autoCompleteEnabled: false,
|
||||
autoCompleteVisibility: "always",
|
||||
autoCompleteVisibility: 'always',
|
||||
autoCompleteWhitelistChatIds: [],
|
||||
autoCompleteBlacklistChatIds: [],
|
||||
},
|
||||
usage: {
|
||||
cycleBudgetUsd: 10,
|
||||
spentThisCycleUsd: 0,
|
||||
spentTodayUsd: 0,
|
||||
cycleStartDate: new Date(),
|
||||
},
|
||||
usage: { cycleBudgetUsd: 10, spentThisCycleUsd: 0, spentTodayUsd: 0, cycleStartDate: new Date() },
|
||||
autoDeleteTelegramMessagesAfterDays: 30,
|
||||
autoDeleteThreadsAfterDays: 30,
|
||||
};
|
||||
|
||||
describe("userSlice", () => {
|
||||
it("starts with null user", () => {
|
||||
describe('userSlice', () => {
|
||||
it('starts with null user', () => {
|
||||
const store = createStore();
|
||||
expect(store.getState().user.user).toBeNull();
|
||||
expect(store.getState().user.isLoading).toBe(false);
|
||||
expect(store.getState().user.error).toBeNull();
|
||||
});
|
||||
|
||||
it("sets user via setUser", () => {
|
||||
it('sets user via setUser', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setUser(mockUser));
|
||||
expect(store.getState().user.user).toEqual(mockUser);
|
||||
expect(store.getState().user.error).toBeNull();
|
||||
});
|
||||
|
||||
it("clears user via clearUser", () => {
|
||||
it('clears user via clearUser', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setUser(mockUser));
|
||||
store.dispatch(clearUser());
|
||||
@@ -61,7 +57,7 @@ describe("userSlice", () => {
|
||||
expect(store.getState().user.error).toBeNull();
|
||||
});
|
||||
|
||||
it("sets user to null via setUser(null)", () => {
|
||||
it('sets user to null via setUser(null)', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setUser(mockUser));
|
||||
store.dispatch(setUser(null));
|
||||
|
||||
+9
-1
@@ -37,9 +37,17 @@ const aiPersistConfig = { key: 'ai', storage, whitelist: ['config'] };
|
||||
// Persist config for skills state (setupComplete per skill)
|
||||
const skillsPersistConfig = { key: 'skills', storage, whitelist: ['skills'] };
|
||||
|
||||
// Persist config for thread UI prefs only (panel width, last viewed for unread)
|
||||
const threadPersistConfig = {
|
||||
key: 'thread',
|
||||
storage,
|
||||
whitelist: ['panelWidth', 'lastViewedAt'],
|
||||
};
|
||||
|
||||
const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
|
||||
const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer);
|
||||
const persistedSkillsReducer = persistReducer(skillsPersistConfig, skillsReducer);
|
||||
const persistedThreadReducer = persistReducer(threadPersistConfig, threadReducer);
|
||||
|
||||
/**
|
||||
* Middleware that syncs the JWT token to the Rust SESSION_SERVICE whenever
|
||||
@@ -80,7 +88,7 @@ export const store = configureStore({
|
||||
ai: persistedAiReducer,
|
||||
skills: persistedSkillsReducer,
|
||||
team: teamReducer,
|
||||
thread: threadReducer,
|
||||
thread: persistedThreadReducer,
|
||||
invite: inviteReducer,
|
||||
},
|
||||
middleware: getDefaultMiddleware => {
|
||||
|
||||
+119
-2
@@ -12,9 +12,18 @@ interface ThreadState {
|
||||
isLoadingMessages: boolean;
|
||||
messagesError: string | null;
|
||||
createStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
deleteStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
deleteError: string | null;
|
||||
purgeStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
sendStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
sendError: string | null;
|
||||
panelWidth: number;
|
||||
/** threadId -> timestamp when user last viewed that thread (for unread indicators) */
|
||||
lastViewedAt: Record<string, number>;
|
||||
/** Suggested starter questions for empty threads (from GET /chat/autocomplete) */
|
||||
suggestedQuestions: Array<{ text: string; confidence: number }>;
|
||||
isLoadingSuggestions: boolean;
|
||||
suggestError: string | null;
|
||||
}
|
||||
|
||||
const initialState: ThreadState = {
|
||||
@@ -26,9 +35,16 @@ const initialState: ThreadState = {
|
||||
isLoadingMessages: false,
|
||||
messagesError: null,
|
||||
createStatus: 'idle',
|
||||
deleteStatus: 'idle',
|
||||
deleteError: null,
|
||||
purgeStatus: 'idle',
|
||||
sendStatus: 'idle',
|
||||
sendError: null,
|
||||
panelWidth: 320,
|
||||
lastViewedAt: {},
|
||||
suggestedQuestions: [],
|
||||
isLoadingSuggestions: false,
|
||||
suggestError: null,
|
||||
};
|
||||
|
||||
export const fetchThreads = createAsyncThunk(
|
||||
@@ -80,6 +96,26 @@ export const createThread = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteThread = createAsyncThunk(
|
||||
'thread/deleteThread',
|
||||
async (threadId: string, { dispatch, getState, rejectWithValue }) => {
|
||||
try {
|
||||
await threadApi.deleteThread(threadId);
|
||||
const state = (getState() as { thread: ThreadState }).thread;
|
||||
if (state.selectedThreadId === threadId) {
|
||||
dispatch(clearSelectedThread());
|
||||
}
|
||||
return threadId;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to delete thread';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const purgeThreads = createAsyncThunk(
|
||||
'thread/purgeThreads',
|
||||
async (_, { dispatch, rejectWithValue }) => {
|
||||
@@ -124,6 +160,22 @@ export const sendMessage = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchSuggestedQuestions = createAsyncThunk(
|
||||
'thread/fetchSuggestedQuestions',
|
||||
async (conversationId: string | undefined, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await threadApi.getSuggestQuestions(conversationId);
|
||||
return data.suggestions;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to load suggestions';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const threadSlice = createSlice({
|
||||
name: 'thread',
|
||||
initialState,
|
||||
@@ -132,15 +184,27 @@ const threadSlice = createSlice({
|
||||
state.selectedThreadId = action.payload;
|
||||
state.messages = [];
|
||||
state.messagesError = null;
|
||||
state.suggestedQuestions = [];
|
||||
state.suggestError = null;
|
||||
},
|
||||
clearSelectedThread: state => {
|
||||
state.selectedThreadId = null;
|
||||
state.messages = [];
|
||||
state.messagesError = null;
|
||||
state.suggestedQuestions = [];
|
||||
state.suggestError = null;
|
||||
},
|
||||
clearSuggestedQuestions: state => {
|
||||
state.suggestedQuestions = [];
|
||||
state.suggestError = null;
|
||||
},
|
||||
clearCreateStatus: state => {
|
||||
state.createStatus = 'idle';
|
||||
},
|
||||
clearDeleteStatus: state => {
|
||||
state.deleteStatus = 'idle';
|
||||
state.deleteError = null;
|
||||
},
|
||||
clearPurgeStatus: state => {
|
||||
state.purgeStatus = 'idle';
|
||||
},
|
||||
@@ -154,15 +218,27 @@ const threadSlice = createSlice({
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
removeOptimisticMessages: state => {
|
||||
state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-'));
|
||||
},
|
||||
clearSendError: state => {
|
||||
state.sendError = null;
|
||||
},
|
||||
setPanelWidth: (state, action: { payload: number }) => {
|
||||
state.panelWidth = action.payload;
|
||||
},
|
||||
setLastViewed: (state, action: { payload: string }) => {
|
||||
const ts = Date.now();
|
||||
state.lastViewedAt[action.payload] = ts;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
// fetchThreads
|
||||
// fetchThreads — only show skeleton on initial load (stale-while-revalidate)
|
||||
.addCase(fetchThreads.pending, state => {
|
||||
state.isLoading = true;
|
||||
if (state.threads.length === 0) {
|
||||
state.isLoading = true;
|
||||
}
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchThreads.fulfilled, (state, action) => {
|
||||
@@ -181,6 +257,11 @@ const threadSlice = createSlice({
|
||||
.addCase(fetchThreadMessages.fulfilled, (state, action) => {
|
||||
state.isLoadingMessages = false;
|
||||
state.messages = action.payload;
|
||||
// Hide suggestions once thread has messages
|
||||
if (action.payload.length > 0) {
|
||||
state.suggestedQuestions = [];
|
||||
state.suggestError = null;
|
||||
}
|
||||
})
|
||||
.addCase(fetchThreadMessages.rejected, (state, action) => {
|
||||
state.isLoadingMessages = false;
|
||||
@@ -199,6 +280,19 @@ const threadSlice = createSlice({
|
||||
.addCase(createThread.rejected, state => {
|
||||
state.createStatus = 'error';
|
||||
})
|
||||
// deleteThread
|
||||
.addCase(deleteThread.pending, state => {
|
||||
state.deleteStatus = 'loading';
|
||||
state.deleteError = null;
|
||||
})
|
||||
.addCase(deleteThread.fulfilled, (state, action) => {
|
||||
state.deleteStatus = 'success';
|
||||
state.threads = state.threads.filter(t => t.id !== action.payload);
|
||||
})
|
||||
.addCase(deleteThread.rejected, (state, action) => {
|
||||
state.deleteStatus = 'error';
|
||||
state.deleteError = action.payload as string;
|
||||
})
|
||||
// purgeThreads
|
||||
.addCase(purgeThreads.pending, state => {
|
||||
state.purgeStatus = 'loading';
|
||||
@@ -218,10 +312,28 @@ const threadSlice = createSlice({
|
||||
})
|
||||
.addCase(sendMessage.fulfilled, state => {
|
||||
state.sendStatus = 'success';
|
||||
state.suggestedQuestions = [];
|
||||
state.suggestError = null;
|
||||
})
|
||||
.addCase(sendMessage.rejected, (state, action) => {
|
||||
state.sendStatus = 'error';
|
||||
state.sendError = action.payload as string;
|
||||
// Remove optimistic messages so the user doesn't see phantom messages
|
||||
state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-'));
|
||||
})
|
||||
// fetchSuggestedQuestions
|
||||
.addCase(fetchSuggestedQuestions.pending, state => {
|
||||
state.isLoadingSuggestions = true;
|
||||
state.suggestError = null;
|
||||
})
|
||||
.addCase(fetchSuggestedQuestions.fulfilled, (state, action) => {
|
||||
state.isLoadingSuggestions = false;
|
||||
state.suggestedQuestions = action.payload;
|
||||
})
|
||||
.addCase(fetchSuggestedQuestions.rejected, (state, action) => {
|
||||
state.isLoadingSuggestions = false;
|
||||
state.suggestError = action.payload as string;
|
||||
state.suggestedQuestions = [];
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -230,8 +342,13 @@ export const {
|
||||
setSelectedThread,
|
||||
clearSelectedThread,
|
||||
clearCreateStatus,
|
||||
clearDeleteStatus,
|
||||
clearPurgeStatus,
|
||||
addOptimisticMessage,
|
||||
removeOptimisticMessages,
|
||||
clearSendError,
|
||||
clearSuggestedQuestions,
|
||||
setPanelWidth,
|
||||
setLastViewed,
|
||||
} = threadSlice.actions;
|
||||
export default threadSlice.reducer;
|
||||
|
||||
+15
-30
@@ -4,9 +4,9 @@
|
||||
* These provide deterministic API responses for testing components
|
||||
* that depend on backend data.
|
||||
*/
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
const BACKEND_URL = "http://localhost:5005";
|
||||
const BACKEND_URL = 'http://localhost:5005';
|
||||
|
||||
export const handlers = [
|
||||
// GET /telegram/me - Current user profile
|
||||
@@ -14,25 +14,22 @@ export const handlers = [
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
_id: "user-123",
|
||||
_id: 'user-123',
|
||||
telegramId: 12345678,
|
||||
hasAccess: true,
|
||||
magicWord: "alpha",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
username: "testuser",
|
||||
role: "user" as const,
|
||||
activeTeamId: "team-1",
|
||||
magicWord: 'alpha',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
username: 'testuser',
|
||||
role: 'user' as const,
|
||||
activeTeamId: 'team-1',
|
||||
referral: {},
|
||||
subscription: {
|
||||
hasActiveSubscription: false,
|
||||
plan: "FREE" as const,
|
||||
},
|
||||
subscription: { hasActiveSubscription: false, plan: 'FREE' as const },
|
||||
settings: {
|
||||
dailySummariesEnabled: false,
|
||||
dailySummaryChatIds: [],
|
||||
autoCompleteEnabled: false,
|
||||
autoCompleteVisibility: "always" as const,
|
||||
autoCompleteVisibility: 'always' as const,
|
||||
autoCompleteWhitelistChatIds: [],
|
||||
autoCompleteBlacklistChatIds: [],
|
||||
},
|
||||
@@ -52,32 +49,20 @@ export const handlers = [
|
||||
http.get(`${BACKEND_URL}/billing/current-plan`, () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
plan: "FREE",
|
||||
hasActiveSubscription: false,
|
||||
planExpiry: null,
|
||||
subscription: null,
|
||||
},
|
||||
data: { plan: 'FREE', hasActiveSubscription: false, planExpiry: null, subscription: null },
|
||||
});
|
||||
}),
|
||||
|
||||
// GET /teams
|
||||
http.get(`${BACKEND_URL}/teams`, () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: [],
|
||||
});
|
||||
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",
|
||||
},
|
||||
sessionToken: 'mock-session-token',
|
||||
user: { id: 'user-123', firstName: 'Test', username: 'testuser' },
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* MSW server instance for use in Vitest tests.
|
||||
*/
|
||||
import { setupServer } from "msw/node";
|
||||
import { handlers } from "./handlers";
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
+35
-56
@@ -7,46 +7,33 @@
|
||||
* - Mocks Tauri-specific modules that aren't available in test env
|
||||
* - Resets rate limiter module-level state between tests
|
||||
*/
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { server } from "./server";
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest';
|
||||
|
||||
import { server } from './server';
|
||||
|
||||
// Mock import.meta.env defaults for tests
|
||||
vi.stubEnv("VITE_BACKEND_URL", "http://localhost:5005");
|
||||
vi.stubEnv("DEV", true);
|
||||
vi.stubEnv("MODE", "test");
|
||||
vi.stubEnv('VITE_BACKEND_URL', 'http://localhost:5005');
|
||||
vi.stubEnv('DEV', true);
|
||||
vi.stubEnv('MODE', 'test');
|
||||
|
||||
// Mock Tauri APIs (not available in test env)
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
}));
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(), emit: vi.fn() }));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-deep-link", () => ({
|
||||
onOpenUrl: vi.fn(),
|
||||
getCurrent: vi.fn(),
|
||||
}));
|
||||
vi.mock('@tauri-apps/plugin-deep-link', () => ({ onOpenUrl: vi.fn(), getCurrent: vi.fn() }));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
vi.mock('@tauri-apps/plugin-opener', () => ({ open: vi.fn() }));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-os", () => ({
|
||||
platform: vi.fn().mockResolvedValue("macos"),
|
||||
}));
|
||||
vi.mock('@tauri-apps/plugin-os', () => ({ platform: vi.fn().mockResolvedValue('macos') }));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-shell", () => ({
|
||||
Command: vi.fn(),
|
||||
open: vi.fn(),
|
||||
}));
|
||||
vi.mock('@tauri-apps/plugin-shell', () => ({ Command: vi.fn(), open: vi.fn() }));
|
||||
|
||||
// Mock tauriCommands to prevent Tauri API calls in tests
|
||||
vi.mock("../utils/tauriCommands", () => ({
|
||||
vi.mock('../utils/tauriCommands', () => ({
|
||||
isTauri: () => false,
|
||||
storeSession: vi.fn().mockResolvedValue(undefined),
|
||||
getAuthState: vi.fn().mockResolvedValue({ is_authenticated: false }),
|
||||
@@ -55,27 +42,22 @@ vi.mock("../utils/tauriCommands", () => ({
|
||||
}));
|
||||
|
||||
// Mock the config module
|
||||
vi.mock("../utils/config", () => ({
|
||||
BACKEND_URL: "http://localhost:5005",
|
||||
TELEGRAM_BOT_USERNAME: "test_bot",
|
||||
TELEGRAM_BOT_ID: "12345",
|
||||
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",
|
||||
SKILLS_GITHUB_REPO: 'test/skills',
|
||||
DEV_AUTO_LOAD_SKILL: undefined,
|
||||
}));
|
||||
|
||||
// Mock redux-persist to avoid CJS/ESM issues in vitest
|
||||
vi.mock("redux-persist", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>(
|
||||
"redux-persist"
|
||||
);
|
||||
vi.mock('redux-persist', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('redux-persist');
|
||||
return {
|
||||
...actual,
|
||||
// Override persistReducer to just return the base reducer
|
||||
persistReducer: (
|
||||
_config: unknown,
|
||||
reducer: (s: unknown, a: unknown) => unknown
|
||||
) => reducer,
|
||||
persistReducer: (_config: unknown, reducer: (s: unknown, a: unknown) => unknown) => reducer,
|
||||
// Override persistStore to return a no-op persistor
|
||||
persistStore: () => ({
|
||||
subscribe: () => () => {},
|
||||
@@ -90,7 +72,7 @@ vi.mock("redux-persist", async () => {
|
||||
});
|
||||
|
||||
// Mock redux-persist integration
|
||||
vi.mock("redux-persist/integration/react", () => ({
|
||||
vi.mock('redux-persist/integration/react', () => ({
|
||||
PersistGate: ({
|
||||
children,
|
||||
}: {
|
||||
@@ -101,16 +83,13 @@ vi.mock("redux-persist/integration/react", () => ({
|
||||
}));
|
||||
|
||||
// Mock redux-logger to avoid noisy test output
|
||||
vi.mock("redux-logger", () => ({
|
||||
createLogger: () =>
|
||||
() =>
|
||||
(next: (action: unknown) => unknown) =>
|
||||
(action: unknown) =>
|
||||
next(action),
|
||||
vi.mock('redux-logger', () => ({
|
||||
createLogger: () => () => (next: (action: unknown) => unknown) => (action: unknown) =>
|
||||
next(action),
|
||||
}));
|
||||
|
||||
// Mock Sentry
|
||||
vi.mock("@sentry/react", () => ({
|
||||
vi.mock('@sentry/react', () => ({
|
||||
init: vi.fn(),
|
||||
ErrorBoundary: ({
|
||||
children,
|
||||
@@ -127,13 +106,13 @@ vi.mock("@sentry/react", () => ({
|
||||
|
||||
// Silence console during tests to keep output clean
|
||||
if (!process.env.DEBUG_TESTS) {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
}
|
||||
|
||||
// MSW server lifecycle
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" }));
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
cleanup();
|
||||
@@ -143,8 +122,8 @@ afterAll(() => server.close());
|
||||
// Reset rate limiter per-request counter before each test.
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const { resetRequestCallCount } = await import("../lib/mcp/rateLimiter");
|
||||
if (typeof resetRequestCallCount === "function") {
|
||||
const { resetRequestCallCount } = await import('../lib/mcp/rateLimiter');
|
||||
if (typeof resetRequestCallCount === 'function') {
|
||||
resetRequestCallCount();
|
||||
}
|
||||
} catch {
|
||||
|
||||
+17
-12
@@ -2,16 +2,16 @@
|
||||
* Test utilities — provides a renderWithProviders helper that wraps
|
||||
* components in a fresh Redux store + MemoryRouter for isolated testing.
|
||||
*/
|
||||
import { render, type RenderOptions } from "@testing-library/react";
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import authReducer from "../store/authSlice";
|
||||
import socketReducer from "../store/socketSlice";
|
||||
import teamReducer from "../store/teamSlice";
|
||||
import userReducer from "../store/userSlice";
|
||||
import authReducer from '../store/authSlice';
|
||||
import socketReducer from '../store/socketSlice';
|
||||
import teamReducer from '../store/teamSlice';
|
||||
import userReducer from '../store/userSlice';
|
||||
|
||||
/**
|
||||
* Creates a fresh Redux store for testing.
|
||||
@@ -21,14 +21,19 @@ export function createTestStore(preloadedState?: Record<string, unknown>) {
|
||||
return configureStore({
|
||||
// Cast reducer map to any to avoid strict typing issues in the test environment.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
reducer: ({ auth: authReducer, socket: socketReducer, user: userReducer, team: teamReducer } as unknown) as any,
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
socket: socketReducer,
|
||||
user: userReducer,
|
||||
team: teamReducer,
|
||||
} as unknown as any,
|
||||
preloadedState: preloadedState as never,
|
||||
});
|
||||
}
|
||||
|
||||
type TestStore = ReturnType<typeof createTestStore>;
|
||||
|
||||
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
|
||||
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
|
||||
preloadedState?: Record<string, unknown>;
|
||||
store?: TestStore;
|
||||
initialEntries?: string[];
|
||||
@@ -42,7 +47,7 @@ export function renderWithProviders(
|
||||
{
|
||||
preloadedState,
|
||||
store = createTestStore(preloadedState),
|
||||
initialEntries = ["/"],
|
||||
initialEntries = ['/'],
|
||||
...renderOptions
|
||||
}: ExtendedRenderOptions = {}
|
||||
) {
|
||||
|
||||
+12
-1
@@ -31,7 +31,18 @@ export interface ThreadCreateData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SendMessageData {
|
||||
export interface ThreadDeleteData {
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
/** Response from POST /chat/sendMessage — send user message and get agent reply */
|
||||
export interface SendMessageResponseData {
|
||||
// Optional: backend can return empty {} or e.g. { messageId: string }
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Response from GET /chat/autocomplete — suggested starter questions for a new thread */
|
||||
export interface SuggestQuestionsData {
|
||||
suggestions: Array<{ text: string; confidence: number }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getArchitectureDisplayName,
|
||||
getDownloadLink,
|
||||
getPlatformDisplayName,
|
||||
parseReleaseAssetsByArchitecture,
|
||||
type GitHubReleaseAsset,
|
||||
} from "../deviceDetection";
|
||||
parseReleaseAssetsByArchitecture,
|
||||
} from '../deviceDetection';
|
||||
|
||||
describe("getArchitectureDisplayName", () => {
|
||||
it("returns correct names for known architectures", () => {
|
||||
expect(getArchitectureDisplayName("x64")).toBe("Intel (x64)");
|
||||
expect(getArchitectureDisplayName("aarch64")).toBe(
|
||||
"Apple Silicon (ARM64)"
|
||||
);
|
||||
expect(getArchitectureDisplayName("amd64")).toBe("AMD64");
|
||||
expect(getArchitectureDisplayName("unknown")).toBe("Unknown");
|
||||
describe('getArchitectureDisplayName', () => {
|
||||
it('returns correct names for known architectures', () => {
|
||||
expect(getArchitectureDisplayName('x64')).toBe('Intel (x64)');
|
||||
expect(getArchitectureDisplayName('aarch64')).toBe('Apple Silicon (ARM64)');
|
||||
expect(getArchitectureDisplayName('amd64')).toBe('AMD64');
|
||||
expect(getArchitectureDisplayName('unknown')).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlatformDisplayName", () => {
|
||||
it("returns human-readable platform names", () => {
|
||||
expect(getPlatformDisplayName("windows")).toBe("Windows");
|
||||
expect(getPlatformDisplayName("macos")).toBe("macOS");
|
||||
expect(getPlatformDisplayName("linux")).toBe("Linux");
|
||||
expect(getPlatformDisplayName("android")).toBe("Android");
|
||||
expect(getPlatformDisplayName("ios")).toBe("iOS");
|
||||
expect(getPlatformDisplayName("unknown")).toBe("Your Device");
|
||||
describe('getPlatformDisplayName', () => {
|
||||
it('returns human-readable platform names', () => {
|
||||
expect(getPlatformDisplayName('windows')).toBe('Windows');
|
||||
expect(getPlatformDisplayName('macos')).toBe('macOS');
|
||||
expect(getPlatformDisplayName('linux')).toBe('Linux');
|
||||
expect(getPlatformDisplayName('android')).toBe('Android');
|
||||
expect(getPlatformDisplayName('ios')).toBe('iOS');
|
||||
expect(getPlatformDisplayName('unknown')).toBe('Your Device');
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDownloadLink", () => {
|
||||
it("returns release links when available", () => {
|
||||
describe('getDownloadLink', () => {
|
||||
it('returns release links when available', () => {
|
||||
const releaseLinks = {
|
||||
windows: "https://releases.example.com/app.exe",
|
||||
macos: "https://releases.example.com/app.dmg",
|
||||
windows: 'https://releases.example.com/app.exe',
|
||||
macos: 'https://releases.example.com/app.dmg',
|
||||
};
|
||||
expect(getDownloadLink("windows", releaseLinks)).toBe(
|
||||
"https://releases.example.com/app.exe"
|
||||
);
|
||||
expect(getDownloadLink("macos", releaseLinks)).toBe(
|
||||
"https://releases.example.com/app.dmg"
|
||||
);
|
||||
expect(getDownloadLink('windows', releaseLinks)).toBe('https://releases.example.com/app.exe');
|
||||
expect(getDownloadLink('macos', releaseLinks)).toBe('https://releases.example.com/app.dmg');
|
||||
});
|
||||
|
||||
it("falls back to dummy links when no release links", () => {
|
||||
const link = getDownloadLink("linux");
|
||||
expect(link).toContain("example.com");
|
||||
expect(link).toContain("linux");
|
||||
it('falls back to dummy links when no release links', () => {
|
||||
const link = getDownloadLink('linux');
|
||||
expect(link).toContain('example.com');
|
||||
expect(link).toContain('linux');
|
||||
});
|
||||
|
||||
it("falls back for unknown and ios platforms", () => {
|
||||
const releaseLinks = { windows: "https://example.com/w.exe" };
|
||||
const unknownLink = getDownloadLink("unknown", releaseLinks);
|
||||
expect(unknownLink).toBe("https://example.com/download");
|
||||
it('falls back for unknown and ios platforms', () => {
|
||||
const releaseLinks = { windows: 'https://example.com/w.exe' };
|
||||
const unknownLink = getDownloadLink('unknown', releaseLinks);
|
||||
expect(unknownLink).toBe('https://example.com/download');
|
||||
|
||||
const iosLink = getDownloadLink("ios", releaseLinks);
|
||||
expect(iosLink).toContain("apple.com");
|
||||
const iosLink = getDownloadLink('ios', releaseLinks);
|
||||
expect(iosLink).toContain('apple.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReleaseAssetsByArchitecture", () => {
|
||||
const makeAsset = (
|
||||
name: string,
|
||||
url?: string
|
||||
): GitHubReleaseAsset => ({
|
||||
describe('parseReleaseAssetsByArchitecture', () => {
|
||||
const makeAsset = (name: string, url?: string): GitHubReleaseAsset => ({
|
||||
name,
|
||||
browser_download_url: url || `https://example.com/${name}`,
|
||||
content_type: "application/octet-stream",
|
||||
content_type: 'application/octet-stream',
|
||||
size: 1000,
|
||||
});
|
||||
|
||||
it("categorizes assets by platform", () => {
|
||||
it('categorizes assets by platform', () => {
|
||||
const assets = [
|
||||
makeAsset("alphahuman-windows-x64-setup.exe"),
|
||||
makeAsset("alphahuman-macos-aarch64.dmg"),
|
||||
makeAsset("alphahuman-linux-x64.AppImage"),
|
||||
makeAsset("alphahuman-android-arm64.apk"),
|
||||
makeAsset('alphahuman-windows-x64-setup.exe'),
|
||||
makeAsset('alphahuman-macos-aarch64.dmg'),
|
||||
makeAsset('alphahuman-linux-x64.AppImage'),
|
||||
makeAsset('alphahuman-android-arm64.apk'),
|
||||
];
|
||||
|
||||
const links = parseReleaseAssetsByArchitecture(assets);
|
||||
@@ -85,27 +77,27 @@ describe("parseReleaseAssetsByArchitecture", () => {
|
||||
expect(links.android).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips .sig signature files", () => {
|
||||
it('skips .sig signature files', () => {
|
||||
const assets = [
|
||||
makeAsset("alphahuman-windows-x64-setup.exe"),
|
||||
makeAsset("alphahuman-windows-x64-setup.exe.sig"),
|
||||
makeAsset('alphahuman-windows-x64-setup.exe'),
|
||||
makeAsset('alphahuman-windows-x64-setup.exe.sig'),
|
||||
];
|
||||
|
||||
const links = parseReleaseAssetsByArchitecture(assets);
|
||||
expect(links.windows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("groups multiple architectures per platform", () => {
|
||||
it('groups multiple architectures per platform', () => {
|
||||
const assets = [
|
||||
makeAsset("alphahuman-macos-x64.dmg"),
|
||||
makeAsset("alphahuman-macos-aarch64.dmg"),
|
||||
makeAsset('alphahuman-macos-x64.dmg'),
|
||||
makeAsset('alphahuman-macos-aarch64.dmg'),
|
||||
];
|
||||
|
||||
const links = parseReleaseAssetsByArchitecture(assets);
|
||||
expect(links.macos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty when no matching assets", () => {
|
||||
it('returns empty when no matching assets', () => {
|
||||
const links = parseReleaseAssetsByArchitecture([]);
|
||||
expect(links.windows).toBeUndefined();
|
||||
expect(links.macos).toBeUndefined();
|
||||
@@ -113,14 +105,14 @@ describe("parseReleaseAssetsByArchitecture", () => {
|
||||
expect(links.android).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers AppImage over deb/rpm for Linux", () => {
|
||||
it('prefers AppImage over deb/rpm for Linux', () => {
|
||||
const assets = [
|
||||
makeAsset("alphahuman-linux-x64.rpm"),
|
||||
makeAsset("alphahuman-linux-x64.AppImage"),
|
||||
makeAsset('alphahuman-linux-x64.rpm'),
|
||||
makeAsset('alphahuman-linux-x64.AppImage'),
|
||||
];
|
||||
|
||||
const links = parseReleaseAssetsByArchitecture(assets);
|
||||
expect(links.linux).toHaveLength(1);
|
||||
expect(links.linux![0].fileName).toContain(".AppImage");
|
||||
expect(links.linux![0].fileName).toContain('.AppImage');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,79 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSafeLogData,
|
||||
sanitizeError,
|
||||
sanitizeForLogging,
|
||||
} from "../sanitize";
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe("sanitizeError", () => {
|
||||
it("extracts name, message, and stack from Error objects", () => {
|
||||
const err = new Error("something broke");
|
||||
import { createSafeLogData, sanitizeError, sanitizeForLogging } from '../sanitize';
|
||||
|
||||
describe('sanitizeError', () => {
|
||||
it('extracts name, message, and stack from Error objects', () => {
|
||||
const err = new Error('something broke');
|
||||
const result = sanitizeError(err) as Record<string, unknown>;
|
||||
expect(result.name).toBe("Error");
|
||||
expect(result.message).toBe("something broke");
|
||||
expect(result.name).toBe('Error');
|
||||
expect(result.message).toBe('something broke');
|
||||
// DEV mode is set in test env, so stack should be present
|
||||
expect(result.stack).toBeDefined();
|
||||
});
|
||||
|
||||
it("sanitizes plain objects by redacting sensitive keys", () => {
|
||||
const input = { token: "abc123", username: "bob" };
|
||||
it('sanitizes plain objects by redacting sensitive keys', () => {
|
||||
const input = { token: 'abc123', username: 'bob' };
|
||||
const result = sanitizeError(input) as Record<string, unknown>;
|
||||
expect(result.token).toBe("[REDACTED]");
|
||||
expect(result.username).toBe("bob");
|
||||
expect(result.token).toBe('[REDACTED]');
|
||||
expect(result.username).toBe('bob');
|
||||
});
|
||||
|
||||
it("returns primitives as-is", () => {
|
||||
expect(sanitizeError("string error")).toBe("string error");
|
||||
it('returns primitives as-is', () => {
|
||||
expect(sanitizeError('string error')).toBe('string error');
|
||||
expect(sanitizeError(42)).toBe(42);
|
||||
expect(sanitizeError(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeForLogging", () => {
|
||||
it("returns null/undefined as-is", () => {
|
||||
describe('sanitizeForLogging', () => {
|
||||
it('returns null/undefined as-is', () => {
|
||||
expect(sanitizeForLogging(null)).toBeNull();
|
||||
expect(sanitizeForLogging(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts sensitive keys in nested objects", () => {
|
||||
it('redacts sensitive keys in nested objects', () => {
|
||||
const input = {
|
||||
user: "alice",
|
||||
config: {
|
||||
apiKey: "secret-key",
|
||||
endpoint: "https://api.example.com",
|
||||
},
|
||||
user: 'alice',
|
||||
config: { apiKey: 'secret-key', endpoint: 'https://api.example.com' },
|
||||
};
|
||||
const result = sanitizeForLogging(input) as Record<string, unknown>;
|
||||
const config = result.config as Record<string, unknown>;
|
||||
expect(config.apiKey).toBe("[REDACTED]");
|
||||
expect(config.endpoint).toBe("https://api.example.com");
|
||||
expect(config.apiKey).toBe('[REDACTED]');
|
||||
expect(config.endpoint).toBe('https://api.example.com');
|
||||
});
|
||||
|
||||
it("truncates large objects and shows preview", () => {
|
||||
it('truncates large objects and shows preview', () => {
|
||||
const bigData: Record<string, string> = {};
|
||||
for (let i = 0; i < 200; i++) {
|
||||
bigData[`field_${i}`] = `value_${"x".repeat(10)}`;
|
||||
bigData[`field_${i}`] = `value_${'x'.repeat(10)}`;
|
||||
}
|
||||
const result = sanitizeForLogging(bigData) as Record<string, unknown>;
|
||||
expect(result._truncated).toBe(true);
|
||||
expect(result._preview).toBeDefined();
|
||||
expect(typeof result._size).toBe("number");
|
||||
expect(typeof result._size).toBe('number');
|
||||
});
|
||||
|
||||
it("sanitizes arrays by iterating each element", () => {
|
||||
it('sanitizes arrays by iterating each element', () => {
|
||||
const input = [
|
||||
{ password: "hidden", name: "alice" },
|
||||
{ password: "hidden", name: "bob" },
|
||||
{ password: 'hidden', name: 'alice' },
|
||||
{ password: 'hidden', name: 'bob' },
|
||||
];
|
||||
const result = sanitizeForLogging(input) as Array<Record<string, unknown>>;
|
||||
expect(result[0].password).toBe("[REDACTED]");
|
||||
expect(result[0].name).toBe("alice");
|
||||
expect(result[1].password).toBe("[REDACTED]");
|
||||
expect(result[0].password).toBe('[REDACTED]');
|
||||
expect(result[0].name).toBe('alice');
|
||||
expect(result[1].password).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it("stops at max recursion depth", () => {
|
||||
it('stops at max recursion depth', () => {
|
||||
// Build deeply nested object
|
||||
let obj: Record<string, unknown> = { value: "deep" };
|
||||
let obj: Record<string, unknown> = { value: 'deep' };
|
||||
for (let i = 0; i < 10; i++) {
|
||||
obj = { nested: obj };
|
||||
}
|
||||
@@ -83,28 +77,25 @@ describe("sanitizeForLogging", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSafeLogData", () => {
|
||||
it("includes metadata and marks hasData=false when no sensitiveData", () => {
|
||||
const result = createSafeLogData({ action: "login" });
|
||||
expect(result.action).toBe("login");
|
||||
describe('createSafeLogData', () => {
|
||||
it('includes metadata and marks hasData=false when no sensitiveData', () => {
|
||||
const result = createSafeLogData({ action: 'login' });
|
||||
expect(result.action).toBe('login');
|
||||
expect(result.hasData).toBe(false);
|
||||
});
|
||||
|
||||
it("includes sanitized data for small payloads", () => {
|
||||
const result = createSafeLogData(
|
||||
{ action: "fetch" },
|
||||
{ userId: "123", token: "secret" }
|
||||
);
|
||||
it('includes sanitized data for small payloads', () => {
|
||||
const result = createSafeLogData({ action: 'fetch' }, { userId: '123', token: 'secret' });
|
||||
expect(result.hasData).toBe(true);
|
||||
expect(result.dataSize).toBeGreaterThan(0);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.token).toBe("[REDACTED]");
|
||||
expect(data.userId).toBe("123");
|
||||
expect(data.token).toBe('[REDACTED]');
|
||||
expect(data.userId).toBe('123');
|
||||
});
|
||||
|
||||
it("shows preview for large payloads", () => {
|
||||
const largePayload = "x".repeat(1000);
|
||||
const result = createSafeLogData({ action: "upload" }, largePayload);
|
||||
it('shows preview for large payloads', () => {
|
||||
const largePayload = 'x'.repeat(1000);
|
||||
const result = createSafeLogData({ action: 'upload' }, largePayload);
|
||||
expect(result.hasData).toBe(true);
|
||||
expect(result.dataSize).toBe(1000);
|
||||
});
|
||||
|
||||
Vendored
-1
@@ -6,4 +6,3 @@ declare namespace NodeJS {
|
||||
}
|
||||
|
||||
declare const browser: any;
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Shared utilities for Appium mac2 + WebDriverIO E2E tests.
|
||||
*
|
||||
@@ -18,14 +16,50 @@
|
||||
* The app starts with a hidden window, so we just wait for the process
|
||||
* to initialize (XCUITest has already launched it).
|
||||
*/
|
||||
export async function waitForApp() {
|
||||
export async function waitForApp(): Promise<void> {
|
||||
await browser.pause(5_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any element matching the predicate exists.
|
||||
* Wait for the accessibility tree to populate with WebView content.
|
||||
*
|
||||
* More reliable than a fixed pause — polls until the tree has a reasonable
|
||||
* number of elements (indicating the WebView has rendered and the
|
||||
* accessibility bridge has exposed them).
|
||||
*
|
||||
* @param {number} [timeout=15000] - Maximum time to wait in milliseconds.
|
||||
* @param {number} [minElements=5] - Minimum element count to consider "ready".
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function elementExists(predicate) {
|
||||
export async function waitForAppReady(
|
||||
timeout: number = 15_000,
|
||||
minElements: number = 5
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
let lastCount = 0;
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const elements = await browser.$$('//*');
|
||||
lastCount = elements.length;
|
||||
if (lastCount >= minElements) return;
|
||||
} catch {
|
||||
// accessibility tree not yet available
|
||||
}
|
||||
await browser.pause(500);
|
||||
}
|
||||
throw new Error(
|
||||
`waitForAppReady timed out after ${timeout}ms: found ${lastCount} elements, ` +
|
||||
`need at least ${minElements}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any element matching the predicate exists.
|
||||
*
|
||||
* @param {string} predicate
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function elementExists(predicate: string): Promise<boolean> {
|
||||
try {
|
||||
const el = await browser.$(`-ios predicate string:${predicate}`);
|
||||
return await el.isExisting();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Deep-link trigger utilities for E2E tests.
|
||||
*
|
||||
* Uses macOS `open` command to fire the custom `alphahuman://` URL scheme,
|
||||
* which the built .app bundle picks up via its registered CFBundleURLSchemes.
|
||||
*/
|
||||
import { exec } from 'child_process';
|
||||
|
||||
/**
|
||||
* Trigger a deep link URL via the macOS `open` command.
|
||||
* Resolves once the OS has dispatched the URL (does NOT wait for the app to
|
||||
* finish handling it).
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function triggerDeepLink(url: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
exec(`open "${url}"`, error => {
|
||||
if (error) reject(new Error(`Failed to trigger deep link: ${error.message}`));
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for auth deep links.
|
||||
*
|
||||
* @param {string} token - The login token to embed in the URL.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function triggerAuthDeepLink(token: string): Promise<void> {
|
||||
return triggerDeepLink(`alphahuman://auth?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* WebView element helpers for Appium mac2 + WebDriverIO E2E tests.
|
||||
*
|
||||
* The mac2 driver exposes WKWebView content through the macOS accessibility
|
||||
* tree. However, web content elements behave differently from native elements:
|
||||
*
|
||||
* - Text in WKWebView may appear as XCUIElementTypeStaticText with the text
|
||||
* in the `value` attribute (not `label`).
|
||||
* - Buttons may appear as XCUIElementTypeButton, XCUIElementTypeLink, or
|
||||
* generic XCUIElementTypeOther with a `title` or `label`.
|
||||
* - The accessibility tree for web content is only populated when the
|
||||
* window (and the WebView) is visible.
|
||||
*
|
||||
* IMPORTANT: We use **XPath** selectors (not iOS predicate strings) because
|
||||
* the mac2 driver's `CONTAINS` predicate crashes with "Can't use in/contains
|
||||
* operator with collection 1" when some element attributes are non-string types.
|
||||
*
|
||||
* IMPORTANT: Standard `element.click()` does NOT work for WKWebView content
|
||||
* on mac2 — the accessibility click doesn't propagate to the DOM event handler.
|
||||
* We use W3C pointer actions (mouse move + click at element coordinates) instead.
|
||||
*/
|
||||
import type { ChainablePromiseElement } from 'webdriverio';
|
||||
|
||||
/**
|
||||
* Build an XPath string literal that safely handles quotes.
|
||||
* If text has no double quotes, wrap in "...".
|
||||
* If text has no single quotes, wrap in '...'.
|
||||
* Otherwise, use concat() to handle mixed quotes.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
function xpathStringLiteral(text: string): string {
|
||||
if (!text.includes('"')) return `"${text}"`;
|
||||
if (!text.includes("'")) return `'${text}'`;
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
for (const ch of text) {
|
||||
if (ch === '"') {
|
||||
if (current) parts.push(`"${current}"`);
|
||||
parts.push("'\"'");
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current) parts.push(`"${current}"`);
|
||||
return `concat(${parts.join(',')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an XPath selector that finds any element whose @label, @value,
|
||||
* or @title attribute contains the given text.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
function xpathContainsText(text: string): string {
|
||||
const literal = xpathStringLiteral(text);
|
||||
return (
|
||||
`//*[contains(@label, ${literal}) or ` +
|
||||
`contains(@value, ${literal}) or ` +
|
||||
`contains(@title, ${literal})]`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a real mouse click at the center of an element using W3C Actions.
|
||||
*
|
||||
* This is required for WKWebView content because `element.click()` only
|
||||
* triggers the accessibility action, which doesn't fire DOM event handlers
|
||||
* on macOS. W3C pointer actions simulate an actual mouse click that the
|
||||
* WebView processes as a DOM event.
|
||||
*
|
||||
* @param {ChainablePromiseElement} el
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function clickAtElement(el: ChainablePromiseElement): Promise<void> {
|
||||
const location = await el.getLocation();
|
||||
const size = await el.getSize();
|
||||
const centerX = Math.round(location.x + size.width / 2);
|
||||
const centerY = Math.round(location.y + size.height / 2);
|
||||
|
||||
await browser.performActions([
|
||||
{
|
||||
type: 'pointer',
|
||||
id: 'mouse1',
|
||||
parameters: { pointerType: 'mouse' },
|
||||
actions: [
|
||||
{ type: 'pointerMove', duration: 10, x: centerX, y: centerY },
|
||||
{ type: 'pointerDown', button: 0 },
|
||||
{ type: 'pause', duration: 50 },
|
||||
{ type: 'pointerUp', button: 0 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
await browser.releaseActions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until an element whose accessible label, value, or title contains
|
||||
* `text` appears. Covers both native UI elements and WKWebView content.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} [timeout=15000]
|
||||
* @returns {Promise<ChainablePromiseElement>}
|
||||
*/
|
||||
export async function waitForText(
|
||||
text: string,
|
||||
timeout: number = 15_000
|
||||
): Promise<ChainablePromiseElement> {
|
||||
const selector = xpathContainsText(text);
|
||||
const el = await browser.$(selector);
|
||||
await el.waitForExist({ timeout, timeoutMsg: `Text "${text}" not found within ${timeout}ms` });
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a button-like element whose label/value/title contains `text`
|
||||
* appears. Falls back to any element containing the text.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} [timeout=15000]
|
||||
* @returns {Promise<ChainablePromiseElement>}
|
||||
*/
|
||||
export async function waitForButton(
|
||||
text: string,
|
||||
timeout: number = 15_000
|
||||
): Promise<ChainablePromiseElement> {
|
||||
const literal = xpathStringLiteral(text);
|
||||
const btnSelector =
|
||||
`//XCUIElementTypeButton[contains(@label, ${literal}) or ` +
|
||||
`contains(@value, ${literal}) or ` +
|
||||
`contains(@title, ${literal})]`;
|
||||
const el = await browser.$(btnSelector);
|
||||
try {
|
||||
await el.waitForExist({ timeout });
|
||||
return el;
|
||||
} catch {
|
||||
return waitForText(text, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking check: does an element with `text` in its label/value/title
|
||||
* exist right now?
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function textExists(text: string): Promise<boolean> {
|
||||
try {
|
||||
const el = await browser.$(xpathContainsText(text));
|
||||
return await el.isExisting();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an XCUIElementTypeWindow to appear, indicating the app window
|
||||
* has been shown (the app starts hidden in tray mode).
|
||||
*
|
||||
* @param {number} [timeout=20000]
|
||||
* @returns {Promise<ChainablePromiseElement>}
|
||||
*/
|
||||
export async function waitForWindowVisible(
|
||||
timeout: number = 20_000
|
||||
): Promise<ChainablePromiseElement> {
|
||||
const selector = '//XCUIElementTypeWindow';
|
||||
const el = await browser.$(selector);
|
||||
await el.waitForExist({ timeout, timeoutMsg: `App window did not appear within ${timeout}ms` });
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a WKWebView (XCUIElementTypeWebView) element to exist inside the
|
||||
* window. This confirms the Tauri WebView is loaded and its accessibility
|
||||
* subtree is available.
|
||||
*
|
||||
* @param {number} [timeout=20000]
|
||||
* @returns {Promise<ChainablePromiseElement>}
|
||||
*/
|
||||
export async function waitForWebView(timeout: number = 20_000): Promise<ChainablePromiseElement> {
|
||||
const selector = '//XCUIElementTypeWebView';
|
||||
const el = await browser.$(selector);
|
||||
await el.waitForExist({ timeout, timeoutMsg: `WebView not found within ${timeout}ms` });
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element containing `text` to appear, then click it using
|
||||
* W3C pointer actions (required for WKWebView on mac2).
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} [timeout=15000]
|
||||
* @returns {Promise<ChainablePromiseElement>}
|
||||
*/
|
||||
export async function clickText(
|
||||
text: string,
|
||||
timeout: number = 15_000
|
||||
): Promise<ChainablePromiseElement> {
|
||||
const el = await waitForText(text, timeout);
|
||||
await clickAtElement(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a button containing `text` to appear, then click it using
|
||||
* W3C pointer actions (required for WKWebView on mac2).
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} [timeout=15000]
|
||||
* @returns {Promise<ChainablePromiseElement>}
|
||||
*/
|
||||
export async function clickButton(
|
||||
text: string,
|
||||
timeout: number = 15_000
|
||||
): Promise<ChainablePromiseElement> {
|
||||
const el = await waitForButton(text, timeout);
|
||||
await clickAtElement(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the current accessibility tree as XML. Useful for debugging which
|
||||
* elements are visible and what attributes they expose.
|
||||
*
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function dumpAccessibilityTree(): Promise<string> {
|
||||
try {
|
||||
const source: string = await browser.getPageSource();
|
||||
return source;
|
||||
} catch (err: unknown) {
|
||||
return `[dumpAccessibilityTree] Failed: ${err}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Local HTTP mock server for E2E tests.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
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)) {
|
||||
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)) {
|
||||
json(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
gatewayTransactionId: 'coinbase_mock_' + Date.now(),
|
||||
hostedUrl: 'http://127.0.0.1:18473/mock-coinbase-checkout',
|
||||
status: 'NEW',
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
},
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* E2E test: Authentication & Access Control + Billing & Subscriptions.
|
||||
*
|
||||
* Covers:
|
||||
* 1.1 User registration via deep link (verified by before() setup)
|
||||
* 1.1.1 Duplicate account handling (re-auth same user)
|
||||
* 1.2 Multi-device sessions (second JWT accepted)
|
||||
* 3.1.1 Default plan allocation (FREE plan on registration)
|
||||
* 3.2.1 Upgrade flow (purchase API call + polling)
|
||||
* 3.2.2 Downgrade flow (lower tiers have no Upgrade button)
|
||||
* 3.3.1 Subscription creation (active subscription display)
|
||||
* 3.3.2 Renewal handling (renewal date display)
|
||||
* 3.3.3 Cancellation handling (Stripe portal API call)
|
||||
* 1.3 Logout via Settings menu
|
||||
* 1.3.1 Revoked session auto-logout
|
||||
*
|
||||
* Each describe block is standalone — the before() hook performs a full
|
||||
* login + onboarding cycle so specs don't depend on each other.
|
||||
*
|
||||
* The mock server runs on http://127.0.0.1:18473 and the .app bundle must
|
||||
* have been built with VITE_BACKEND_URL pointing there.
|
||||
*/
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
clickButton,
|
||||
clickText,
|
||||
dumpAccessibilityTree,
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
setMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Click a native XCUIElementTypeButton by its label/title attribute.
|
||||
* Unlike clickText which matches any element, this targets only buttons.
|
||||
* Required when text labels sit next to (but outside) the button bounds.
|
||||
*/
|
||||
async function clickNativeButton(text, timeout = 10_000) {
|
||||
const selector =
|
||||
`//XCUIElementTypeButton[contains(@label, "${text}") or ` + `contains(@title, "${text}")]`;
|
||||
const el = await browser.$(selector);
|
||||
await el.waitForExist({ timeout, timeoutMsg: `Button "${text}" not found within ${timeout}ms` });
|
||||
|
||||
const location = await el.getLocation();
|
||||
const size = await el.getSize();
|
||||
const centerX = Math.round(location.x + size.width / 2);
|
||||
const centerY = Math.round(location.y + size.height / 2);
|
||||
|
||||
await browser.performActions([
|
||||
{
|
||||
type: 'pointer',
|
||||
id: 'mouse1',
|
||||
parameters: { pointerType: 'mouse' },
|
||||
actions: [
|
||||
{ type: 'pointerMove', duration: 10, x: centerX, y: centerY },
|
||||
{ type: 'pointerDown', button: 0 },
|
||||
{ type: 'pause', duration: 50 },
|
||||
{ type: 'pointerUp', button: 0 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
await browser.releaseActions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the mock server request log until a matching request appears.
|
||||
*/
|
||||
async function waitForRequest(method, urlFragment, timeout = 15_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const log = getRequestLog();
|
||||
const match = log.find(r => r.method === method && r.url.includes(urlFragment));
|
||||
if (match) return match;
|
||||
await browser.pause(500);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the given text disappears from the accessibility tree.
|
||||
*/
|
||||
async function waitForTextToDisappear(text, timeout = 10_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
if (!(await textExists(text))) return true;
|
||||
await browser.pause(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until one of the candidate texts appears on screen (Home page markers).
|
||||
* Returns the matched text or null.
|
||||
*/
|
||||
async function waitForHomePage(timeout = 15_000) {
|
||||
const candidates = [
|
||||
'Test',
|
||||
'Good morning',
|
||||
'Good afternoon',
|
||||
'Good evening',
|
||||
'Message AlphaHuman',
|
||||
'Upgrade to Premium',
|
||||
];
|
||||
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
for (const text of candidates) {
|
||||
if (await textExists(text)) return text;
|
||||
}
|
||||
await browser.pause(1_000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until one of the Welcome/public page markers appears.
|
||||
* Returns the matched text or null.
|
||||
*/
|
||||
async function waitForPublicPage(timeout = 15_000) {
|
||||
const candidates = ['Welcome', 'Log in', 'Sign in', 'Get Started', 'alphahuman'];
|
||||
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
for (const text of candidates) {
|
||||
if (await textExists(text)) return text;
|
||||
}
|
||||
await browser.pause(1_000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the first matching text from a list of candidates, with retry.
|
||||
* Returns the text that was clicked, or null if none found.
|
||||
*/
|
||||
async function clickFirstCandidate(candidates, label, timeout = 10_000) {
|
||||
// First attempt
|
||||
for (const text of candidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, timeout);
|
||||
console.log(`[AuthAccess] ${label}: clicked "${text}"`);
|
||||
|
||||
// Verify the click advanced (text should disappear)
|
||||
const advanced = await waitForTextToDisappear(text, 8_000);
|
||||
if (advanced) return text;
|
||||
|
||||
// If text didn't disappear, retry the click
|
||||
console.log(`[AuthAccess] ${label}: "${text}" still visible, retrying click...`);
|
||||
await clickText(text, 5_000);
|
||||
const retryAdvanced = await waitForTextToDisappear(text, 5_000);
|
||||
if (retryAdvanced) return text;
|
||||
|
||||
// Still stuck — log and return null so callers surface the failure
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(
|
||||
`[AuthAccess] ${label}: "${text}" still visible after retry. Tree:\n`,
|
||||
tree.slice(0, 4000)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// If no candidate found, dump tree for debugging
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(`[AuthAccess] ${label}: no candidates found. Tree:\n`, tree.slice(0, 4000));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Billing panel: Settings → Billing & Usage.
|
||||
* Returns when billing page content is visible.
|
||||
*/
|
||||
async function navigateToBilling() {
|
||||
await clickNativeButton('Settings', 10_000);
|
||||
console.log('[AuthAccess] Clicked Settings nav');
|
||||
await browser.pause(2_000);
|
||||
|
||||
// Click "Billing" or "Billing & Usage" menu item
|
||||
const billingCandidates = ['Billing & Usage', 'Billing'];
|
||||
let clicked = false;
|
||||
for (const text of billingCandidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, 10_000);
|
||||
console.log(`[AuthAccess] Clicked "${text}" menu item`);
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!clicked) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] Billing menu item not found. Tree:\n', tree.slice(0, 6000));
|
||||
throw new Error('Billing menu item not found in Settings');
|
||||
}
|
||||
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate from Settings/Billing back to Home via the sidebar Home button.
|
||||
*/
|
||||
async function navigateToHome() {
|
||||
await clickNativeButton('Home', 10_000);
|
||||
console.log('[AuthAccess] Clicked Home nav');
|
||||
await browser.pause(2_000);
|
||||
const homeText = await waitForHomePage(10_000);
|
||||
if (!homeText) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] navigateToHome: Home page not reached. Tree:\n', tree.slice(0, 4000));
|
||||
throw new Error('navigateToHome: Home page not reached after clicking Home nav');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the full login + onboarding flow via deep link.
|
||||
* Leaves the app on the Home page.
|
||||
*/
|
||||
async function performFullLogin(token = 'e2e-test-token') {
|
||||
await triggerAuthDeepLink(token);
|
||||
|
||||
// Wait for window + WebView + accessibility tree
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
|
||||
// Onboarding Step 1: InviteCodeStep — skip
|
||||
await clickText('Skip for now', 10_000);
|
||||
console.log('[AuthAccess] Clicked "Skip for now"');
|
||||
|
||||
const stepChanged = await waitForTextToDisappear('Skip for now', 8_000);
|
||||
if (!stepChanged) {
|
||||
console.log('[AuthAccess] Step did not advance, retrying...');
|
||||
await clickText('Skip', 5_000);
|
||||
await waitForTextToDisappear('Skip', 5_000);
|
||||
}
|
||||
await browser.pause(2_000);
|
||||
|
||||
// Onboarding Step 2: FeaturesStep
|
||||
const featResult = await clickFirstCandidate(['Looks Amazing', 'Bring It On'], 'FeaturesStep');
|
||||
if (!featResult) throw new Error('FeaturesStep button not found');
|
||||
await browser.pause(2_000);
|
||||
|
||||
// Onboarding Step 3: PrivacyStep
|
||||
const privResult = await clickFirstCandidate(['Got it', 'Continue'], 'PrivacyStep');
|
||||
if (!privResult) throw new Error('PrivacyStep button not found');
|
||||
await browser.pause(2_000);
|
||||
|
||||
// Onboarding Step 4: GetStartedStep
|
||||
const startResult = await clickFirstCandidate(["Let's Go", "I'm Ready"], 'GetStartedStep');
|
||||
if (!startResult) throw new Error('GetStartedStep button not found');
|
||||
await browser.pause(3_000);
|
||||
|
||||
// Verify we landed on Home
|
||||
const homeText = await waitForHomePage(15_000);
|
||||
if (!homeText) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(
|
||||
'[AuthAccess] Home page not reached after onboarding. Tree:\n',
|
||||
tree.slice(0, 4000)
|
||||
);
|
||||
throw new Error('Full login + onboarding did not reach Home page');
|
||||
}
|
||||
console.log(`[AuthAccess] Home page confirmed: found "${homeText}"`);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Test suite
|
||||
// ===========================================================================
|
||||
|
||||
describe('Auth & Access Control', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
|
||||
// Perform full login + onboarding so subsequent tests start from Home
|
||||
await performFullLogin('e2e-auth-token');
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
this.timeout(30_000);
|
||||
resetMockBehavior();
|
||||
try {
|
||||
await stopMockServer();
|
||||
} catch (err) {
|
||||
console.log('[AuthAccess] stopMockServer error (non-fatal):', err);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.1 User Registration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('new user registers via deep link and reaches home', async () => {
|
||||
// This was already verified by before() — just assert the API calls
|
||||
const consumeCall = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
|
||||
);
|
||||
expect(consumeCall).toBeDefined();
|
||||
|
||||
const meCall = getRequestLog().find(r => r.method === 'GET' && r.url.includes('/telegram/me'));
|
||||
expect(meCall).toBeDefined();
|
||||
|
||||
// Confirm we're on Home
|
||||
const homeText = await waitForHomePage(5_000);
|
||||
expect(homeText).not.toBeNull();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.1.1 Duplicate Account Handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('re-authenticating with a new token for the same user returns to home', async () => {
|
||||
clearRequestLog();
|
||||
|
||||
// Trigger a second deep link — backend returns the same MOCK_USER
|
||||
await triggerAuthDeepLink('e2e-dup-token');
|
||||
await browser.pause(5_000);
|
||||
|
||||
// App should process the token and stay on / return to Home
|
||||
// (already onboarded, so ProtectedRoute sends to /home)
|
||||
const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 10_000);
|
||||
if (!consumeCall) {
|
||||
console.log('[AuthAccess] Dup request log:', JSON.stringify(getRequestLog(), null, 2));
|
||||
}
|
||||
expect(consumeCall).toBeDefined();
|
||||
|
||||
const homeText = await waitForHomePage(10_000);
|
||||
if (!homeText) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] Dup: not on Home. Tree:\n', tree.slice(0, 4000));
|
||||
}
|
||||
expect(homeText).not.toBeNull();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.2 Multi-Device Sessions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('second device token is accepted and processed', async () => {
|
||||
clearRequestLog();
|
||||
|
||||
// Mock returns a different JWT for "device 2"
|
||||
setMockBehavior('jwt', 'device2');
|
||||
|
||||
await triggerAuthDeepLink('e2e-device2-token');
|
||||
await browser.pause(5_000);
|
||||
|
||||
const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 10_000);
|
||||
if (!consumeCall) {
|
||||
console.log('[AuthAccess] Device2 request log:', JSON.stringify(getRequestLog(), null, 2));
|
||||
}
|
||||
expect(consumeCall).toBeDefined();
|
||||
|
||||
// App should land on Home (already onboarded) or onboarding
|
||||
const homeText = await waitForHomePage(10_000);
|
||||
expect(homeText).not.toBeNull();
|
||||
|
||||
// Reset for next tests
|
||||
resetMockBehavior();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.1 Plan Assignment
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('3.1.1 — new user is assigned FREE plan by default', async () => {
|
||||
// Navigate to Settings → Billing & Usage
|
||||
await navigateToBilling();
|
||||
|
||||
// Verify billing page loaded with plan info
|
||||
const hasPlanText = await textExists('Your Current Plan');
|
||||
if (!hasPlanText) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] Billing page tree:\n', tree.slice(0, 6000));
|
||||
}
|
||||
expect(hasPlanText).toBe(true);
|
||||
|
||||
// Verify FREE plan is shown
|
||||
const hasFree = await textExists('FREE');
|
||||
expect(hasFree).toBe(true);
|
||||
|
||||
// Verify "Current" badge is displayed (next to the Free tier card)
|
||||
const hasCurrent = await textExists('Current');
|
||||
expect(hasCurrent).toBe(true);
|
||||
|
||||
// Verify "Upgrade" button exists (for BASIC and/or PRO tiers)
|
||||
const hasUpgrade = await textExists('Upgrade');
|
||||
expect(hasUpgrade).toBe(true);
|
||||
|
||||
console.log('[AuthAccess] 3.1.1 — FREE plan verified in billing');
|
||||
|
||||
// Navigate back to Home for next tests
|
||||
await navigateToHome();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.2 Plan Changes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('3.2.1 — upgrade initiates purchase flow via Stripe', async () => {
|
||||
// Navigate to billing
|
||||
await navigateToBilling();
|
||||
clearRequestLog();
|
||||
|
||||
// Click the first "Upgrade" button (BASIC tier, appears before PRO)
|
||||
await clickText('Upgrade', 10_000);
|
||||
console.log('[AuthAccess] Clicked Upgrade button');
|
||||
await browser.pause(3_000);
|
||||
|
||||
// Verify POST /payments/stripe/purchasePlan was called
|
||||
const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000);
|
||||
if (!purchaseCall) {
|
||||
console.log('[AuthAccess] Purchase request log:', JSON.stringify(getRequestLog(), null, 2));
|
||||
}
|
||||
expect(purchaseCall).toBeDefined();
|
||||
|
||||
// Verify the request body contains a BASIC plan identifier
|
||||
if (purchaseCall?.body) {
|
||||
const bodyStr = typeof purchaseCall.body === 'string' ? purchaseCall.body : '';
|
||||
console.log('[AuthAccess] Purchase request body:', bodyStr);
|
||||
expect(bodyStr).toContain('BASIC');
|
||||
}
|
||||
|
||||
// Verify the app entered purchasing state ("Waiting..." button text)
|
||||
const hasWaiting =
|
||||
(await textExists('Waiting')) || (await textExists('Waiting for payment confirmation'));
|
||||
console.log(`[AuthAccess] Purchasing state visible: ${hasWaiting}`);
|
||||
expect(hasWaiting).toBe(true);
|
||||
|
||||
// Switch mock to BASIC plan so polling succeeds
|
||||
setMockBehavior('plan', 'BASIC');
|
||||
setMockBehavior('planActive', 'true');
|
||||
setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString());
|
||||
|
||||
// Wait for polling to detect the plan change (polls every 5s, give it 20s)
|
||||
const waitingGone = await waitForTextToDisappear('Waiting', 20_000);
|
||||
console.log(`[AuthAccess] Purchasing state cleared: ${waitingGone}`);
|
||||
|
||||
console.log('[AuthAccess] 3.2.1 — Upgrade purchase flow verified');
|
||||
|
||||
// Navigate back to Home
|
||||
await navigateToHome();
|
||||
});
|
||||
|
||||
it('3.2.2 — lower tier does not show Upgrade button (downgrade not available)', async () => {
|
||||
// Mock is already set to BASIC plan from previous test.
|
||||
// Trigger deep link re-auth to refresh team state with BASIC subscription.
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLink('e2e-billing-refresh-token');
|
||||
await browser.pause(5_000);
|
||||
|
||||
// Wait for the re-auth to complete and reach Home
|
||||
const homeText = await waitForHomePage(15_000);
|
||||
expect(homeText).not.toBeNull();
|
||||
console.log('[AuthAccess] Re-authed with BASIC plan, on Home');
|
||||
|
||||
// Navigate to billing
|
||||
await navigateToBilling();
|
||||
|
||||
// Verify BASIC is the current plan
|
||||
const hasBasic = (await textExists('BASIC')) || (await textExists('Basic'));
|
||||
expect(hasBasic).toBe(true);
|
||||
|
||||
// Verify "Current" badge is visible (next to BASIC tier)
|
||||
const hasCurrent = await textExists('Current');
|
||||
expect(hasCurrent).toBe(true);
|
||||
|
||||
// Count all elements containing "Upgrade" — only PRO should have the button.
|
||||
// Free is a downgrade from BASIC so isUpgrade() returns false → no Upgrade button.
|
||||
const upgradeSelector = `//*[contains(@label, "Upgrade") or contains(@value, "Upgrade") or contains(@title, "Upgrade")]`;
|
||||
const upgradeElements = await browser.$$(upgradeSelector);
|
||||
const upgradeCount = upgradeElements.length;
|
||||
console.log(`[AuthAccess] Found ${upgradeCount} "Upgrade" element(s)`);
|
||||
expect(upgradeCount).toBe(1);
|
||||
|
||||
// Verify PRO plan is visible — the only tier above BASIC that should show Upgrade
|
||||
const hasPro = (await textExists('PRO')) || (await textExists('Pro'));
|
||||
expect(hasPro).toBe(true);
|
||||
console.log('[AuthAccess] 3.2.2 — Exactly 1 Upgrade (PRO only), downgrade not offered');
|
||||
|
||||
// Stay on billing for the next subscription lifecycle tests
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.3 Subscription Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('3.3.1 — active subscription is displayed correctly', async () => {
|
||||
// Still on billing page from previous test with BASIC plan active.
|
||||
// Verify subscription indicators are visible.
|
||||
|
||||
// The mock has planActive='true', so "Manage Subscription" should appear
|
||||
const hasManage = await textExists('Manage Subscription');
|
||||
if (!hasManage) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] Manage Subscription not found. Tree:\n', tree.slice(0, 6000));
|
||||
}
|
||||
expect(hasManage).toBe(true);
|
||||
|
||||
// Verify the current plan API was called (BillingPanel fetches on mount)
|
||||
const planCall = getRequestLog().find(
|
||||
r => r.method === 'GET' && r.url.includes('/payments/stripe/currentPlan')
|
||||
);
|
||||
expect(planCall).toBeDefined();
|
||||
|
||||
console.log('[AuthAccess] 3.3.1 — Active subscription display verified');
|
||||
});
|
||||
|
||||
it('3.3.2 — renewal date is displayed for active subscription', async () => {
|
||||
// Still on billing page with BASIC plan active and planExpiry set.
|
||||
// The mock has planExpiry set to ~30 days from now.
|
||||
const hasRenews = await textExists('Renews');
|
||||
if (!hasRenews) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] Renews text not found. Tree:\n', tree.slice(0, 6000));
|
||||
}
|
||||
expect(hasRenews).toBe(true);
|
||||
|
||||
console.log('[AuthAccess] 3.3.2 — Renewal date display verified');
|
||||
});
|
||||
|
||||
it('3.3.3 — manage subscription opens Stripe portal', async () => {
|
||||
// Still on billing page with active subscription.
|
||||
clearRequestLog();
|
||||
|
||||
// Click "Manage Subscription"
|
||||
await clickText('Manage Subscription', 10_000);
|
||||
console.log('[AuthAccess] Clicked Manage Subscription');
|
||||
await browser.pause(3_000);
|
||||
|
||||
// Verify POST /payments/stripe/portal was called
|
||||
const portalCall = await waitForRequest('POST', '/payments/stripe/portal', 10_000);
|
||||
if (!portalCall) {
|
||||
console.log('[AuthAccess] Portal request log:', JSON.stringify(getRequestLog(), null, 2));
|
||||
}
|
||||
expect(portalCall).toBeDefined();
|
||||
|
||||
console.log('[AuthAccess] 3.3.3 — Stripe portal API call verified');
|
||||
|
||||
// Reset billing mock behavior and navigate back to Home for logout tests
|
||||
resetMockBehavior();
|
||||
await navigateToHome();
|
||||
|
||||
// Re-auth to restore clean state (FREE plan) for logout tests
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLink('e2e-pre-logout-token');
|
||||
await browser.pause(5_000);
|
||||
const homeAfterReset = await waitForHomePage(15_000);
|
||||
expect(homeAfterReset).not.toBeNull();
|
||||
console.log('[AuthAccess] Restored clean state for logout tests');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.3 Logout & Revocation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('user can log out via Settings and returns to Welcome', async () => {
|
||||
// Open Settings — must click the actual Button element, not the text label
|
||||
// next to it (they have separate bounding boxes in the sidebar).
|
||||
await clickNativeButton('Settings', 10_000);
|
||||
console.log('[AuthAccess] Clicked Settings button');
|
||||
await browser.pause(3_000);
|
||||
|
||||
// Verify we navigated to the Settings page
|
||||
const settingsTree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] Settings page tree:\n', settingsTree.slice(0, 6000));
|
||||
|
||||
// Look for "Log out" or related text — it's a <button> element in the
|
||||
// settings page with title "Log out" rendered as text inside it.
|
||||
const logoutCandidates = ['Log out', 'Sign out', 'Logout', 'log out'];
|
||||
let logoutFound = false;
|
||||
|
||||
for (const text of logoutCandidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, 5_000);
|
||||
console.log(`[AuthAccess] Clicked logout: "${text}"`);
|
||||
logoutFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!logoutFound) {
|
||||
// Settings page may need scrolling — "Log out" is at the bottom.
|
||||
// Use mouse wheel scroll inside the content area.
|
||||
console.log('[AuthAccess] Log out not visible, attempting scroll...');
|
||||
try {
|
||||
const webView = await browser.$('//XCUIElementTypeWebView');
|
||||
const loc = await webView.getLocation();
|
||||
const size = await webView.getSize();
|
||||
// Scroll inside the right content area (not the sidebar)
|
||||
const scrollX = Math.round(loc.x + size.width * 0.65);
|
||||
const startY = Math.round(loc.y + size.height * 0.8);
|
||||
const endY = Math.round(loc.y + size.height * 0.2);
|
||||
|
||||
await browser.performActions([
|
||||
{
|
||||
type: 'pointer',
|
||||
id: 'scroll1',
|
||||
parameters: { pointerType: 'mouse' },
|
||||
actions: [
|
||||
{ type: 'pointerMove', duration: 10, x: scrollX, y: startY },
|
||||
{ type: 'pointerDown', button: 0 },
|
||||
{ type: 'pointerMove', duration: 300, x: scrollX, y: endY },
|
||||
{ type: 'pointerUp', button: 0 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
await browser.releaseActions();
|
||||
await browser.pause(2_000);
|
||||
} catch (scrollErr) {
|
||||
console.log('[AuthAccess] Scroll attempt failed:', scrollErr);
|
||||
}
|
||||
|
||||
// Dump tree after scroll to see what's now visible
|
||||
const afterScrollTree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] After scroll tree:\n', afterScrollTree.slice(0, 6000));
|
||||
|
||||
// Try again after scroll
|
||||
for (const text of logoutCandidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, 5_000);
|
||||
console.log(`[AuthAccess] Clicked logout after scroll: "${text}"`);
|
||||
logoutFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!logoutFound) {
|
||||
throw new Error('Could not find logout button in Settings');
|
||||
}
|
||||
|
||||
await browser.pause(3_000);
|
||||
|
||||
// Should land on the public Welcome page
|
||||
const publicText = await waitForPublicPage(15_000);
|
||||
if (!publicText) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] After logout, not on public page. Tree:\n', tree.slice(0, 4000));
|
||||
}
|
||||
expect(publicText).not.toBeNull();
|
||||
console.log(`[AuthAccess] Post-logout public page confirmed: found "${publicText}"`);
|
||||
|
||||
// Verify no protected content is visible
|
||||
const homeStillVisible = await textExists('Message AlphaHuman');
|
||||
expect(homeStillVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('revoked session auto-logs out the user', async () => {
|
||||
// After the previous test logged out, we're on the public page.
|
||||
// Set mock to return 401 on /telegram/me (session revoked)
|
||||
setMockBehavior('session', 'revoked');
|
||||
clearRequestLog();
|
||||
|
||||
// Login again via deep link — token consume succeeds, but /telegram/me returns 401
|
||||
await triggerAuthDeepLink('e2e-revoked-token');
|
||||
await browser.pause(5_000);
|
||||
|
||||
// The app gets a JWT, navigates to onboarding/home, UserProvider calls
|
||||
// /telegram/me -> 401 -> clearToken() -> redirect to Welcome
|
||||
const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 10_000);
|
||||
expect(consumeCall).toBeDefined();
|
||||
|
||||
// Wait for the auto-logout cycle: login -> fetch user -> 401 -> clearToken -> public page
|
||||
await browser.pause(5_000);
|
||||
|
||||
const publicText = await waitForPublicPage(15_000);
|
||||
if (!publicText) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(
|
||||
'[AuthAccess] After revocation, not on public page. Tree:\n',
|
||||
tree.slice(0, 4000)
|
||||
);
|
||||
}
|
||||
expect(publicText).not.toBeNull();
|
||||
console.log(`[AuthAccess] Revoked session auto-logout confirmed: found "${publicText}"`);
|
||||
|
||||
resetMockBehavior();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* E2E test: Complete login → onboarding → home flow via deep link.
|
||||
*
|
||||
* Verifies the full auth + onboarding journey using mock data:
|
||||
* 1. `alphahuman://auth?token=...` deep link is triggered
|
||||
* 2. App calls POST /telegram/login-tokens/:token/consume (mock server)
|
||||
* 3. App receives JWT, dispatches to Redux, navigates to #/onboarding
|
||||
* 4. UserProvider calls GET /telegram/me (mock server)
|
||||
* 5. UserProvider calls GET /teams (mock server)
|
||||
* 6. Onboarding Step 1: InviteCodeStep — skip
|
||||
* 7. Onboarding Step 2: FeaturesStep — click through
|
||||
* 8. Onboarding Step 3: PrivacyStep — click through
|
||||
* 9. Onboarding Step 4: GetStartedStep — complete onboarding
|
||||
* 10. App calls POST /telegram/settings/onboarding-complete (mock server)
|
||||
* 11. App navigates to #/home — greeting with mock user's name shown
|
||||
*
|
||||
* The mock server runs on http://127.0.0.1:18473 and the .app bundle must
|
||||
* have been built with VITE_BACKEND_URL pointing there.
|
||||
*/
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
clickText,
|
||||
dumpAccessibilityTree,
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { clearRequestLog, getRequestLog, startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
/**
|
||||
* Poll the mock server request log until a matching request appears.
|
||||
*/
|
||||
async function waitForRequest(method, urlFragment, timeout = 15_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const log = getRequestLog();
|
||||
const match = log.find(r => r.method === method && r.url.includes(urlFragment));
|
||||
if (match) return match;
|
||||
await browser.pause(500);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the given text disappears from the accessibility tree,
|
||||
* indicating a page/step transition. Falls back after timeout.
|
||||
*/
|
||||
async function waitForTextToDisappear(text, timeout = 10_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
if (!(await textExists(text))) return true;
|
||||
await browser.pause(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe('Login flow — complete with mock data', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
// Give the app time to finish launching (it starts hidden in tray mode)
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 1: Deep link authentication
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('app starts with window hidden (tray app)', async () => {
|
||||
const menuBar = await browser.$('//XCUIElementTypeMenuBar');
|
||||
expect(await menuBar.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it('deep link triggers login and shows the app window', async () => {
|
||||
await triggerAuthDeepLink('e2e-test-token');
|
||||
|
||||
// The deep link handler calls invoke('show_window')
|
||||
await waitForWindowVisible(25_000);
|
||||
|
||||
// Wait for the WebView to appear
|
||||
await waitForWebView(15_000);
|
||||
|
||||
// Wait for the accessibility tree to populate
|
||||
await waitForAppReady(15_000);
|
||||
});
|
||||
|
||||
it('mock server received the token-consume call', async () => {
|
||||
const call = await waitForRequest('POST', '/telegram/login-tokens/');
|
||||
if (!call) {
|
||||
console.log('[LoginFlow] Request log:', JSON.stringify(getRequestLog(), null, 2));
|
||||
}
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
it('mock server received the user-profile call', async () => {
|
||||
const call = await waitForRequest('GET', '/telegram/me');
|
||||
if (!call) {
|
||||
console.log('[LoginFlow] Request log:', JSON.stringify(getRequestLog(), null, 2));
|
||||
}
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: Onboarding — walk through all 4 steps
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('onboarding InviteCodeStep is visible', async () => {
|
||||
const candidates = ['Invite Code', 'Have an Invite Code', 'Skip for now', 'Redeem Code'];
|
||||
|
||||
let found = false;
|
||||
for (const text of candidates) {
|
||||
if (await textExists(text)) {
|
||||
console.log(`[LoginFlow] InviteCodeStep visible: "${text}"`);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[LoginFlow] InviteCodeStep text not found. Tree:\n', tree.slice(0, 3000));
|
||||
}
|
||||
|
||||
const webView = await browser.$('//XCUIElementTypeWebView');
|
||||
expect(await webView.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it('skip invite code step → advances to FeaturesStep', async () => {
|
||||
// Click "Skip for now"
|
||||
await clickText('Skip for now', 10_000);
|
||||
console.log("[LoginFlow] Clicked 'Skip for now'");
|
||||
|
||||
// Verify the step actually changed — wait for InviteCodeStep content to
|
||||
// disappear and FeaturesStep content to appear.
|
||||
const stepChanged = await waitForTextToDisappear('Skip for now', 8_000);
|
||||
if (stepChanged) {
|
||||
console.log('[LoginFlow] InviteCodeStep content disappeared — step advanced');
|
||||
} else {
|
||||
// If text didn't disappear, try clicking again (first click may have
|
||||
// hit the wrong area)
|
||||
console.log("[LoginFlow] Step didn't advance, retrying click...");
|
||||
await clickText('Skip', 5_000);
|
||||
const retryWorked = await waitForTextToDisappear('Skip', 5_000);
|
||||
if (!retryWorked) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(
|
||||
'[LoginFlow] InviteCodeStep still visible after retry. Tree:\n',
|
||||
tree.slice(0, 4000)
|
||||
);
|
||||
throw new Error(
|
||||
'InviteCodeStep did not advance after two click attempts — ' +
|
||||
"'Skip' text still visible in accessibility tree"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Small pause for React state update + re-render
|
||||
await browser.pause(2_000);
|
||||
|
||||
// Dump tree to see what's on screen now
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[LoginFlow] After skip, accessibility tree:\n', tree.slice(0, 4000));
|
||||
});
|
||||
|
||||
it('FeaturesStep — click through', async () => {
|
||||
// FeaturesStep button: "Looks Amazing. Bring It On 🚀"
|
||||
// Emoji may not appear in accessibility tree, try multiple variants
|
||||
const buttonCandidates = ['Looks Amazing', 'Bring It On'];
|
||||
|
||||
let clicked = false;
|
||||
for (const text of buttonCandidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, 5_000);
|
||||
console.log(`[LoginFlow] FeaturesStep: clicked "${text}"`);
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[LoginFlow] FeaturesStep button not found. Tree:\n', tree.slice(0, 4000));
|
||||
throw new Error('Could not find FeaturesStep button');
|
||||
}
|
||||
|
||||
await browser.pause(2_000);
|
||||
});
|
||||
|
||||
it('PrivacyStep — click through', async () => {
|
||||
// PrivacyStep button: "Got it! Let's Continue 👀"
|
||||
const buttonCandidates = ['Got it', 'Continue'];
|
||||
|
||||
let clicked = false;
|
||||
for (const text of buttonCandidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, 5_000);
|
||||
console.log(`[LoginFlow] PrivacyStep: clicked "${text}"`);
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[LoginFlow] PrivacyStep button not found. Tree:\n', tree.slice(0, 4000));
|
||||
throw new Error('Could not find PrivacyStep button');
|
||||
}
|
||||
|
||||
await browser.pause(2_000);
|
||||
});
|
||||
|
||||
it('GetStartedStep — complete onboarding', async () => {
|
||||
// GetStartedStep button: "I'm Ready! Let's Go! 🔥"
|
||||
// NOTE: Do NOT use "Ready" — it matches the heading "You Are Ready, Soldier!"
|
||||
// which is NOT inside the button and won't trigger handleComplete().
|
||||
const buttonCandidates = ["Let's Go", "I'm Ready"];
|
||||
|
||||
let clicked = false;
|
||||
for (const text of buttonCandidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, 5_000);
|
||||
console.log(`[LoginFlow] GetStartedStep: clicked "${text}"`);
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[LoginFlow] GetStartedStep button not found. Tree:\n', tree.slice(0, 4000));
|
||||
throw new Error('Could not find GetStartedStep button');
|
||||
}
|
||||
|
||||
// Wait for the onboarding-complete API call + navigation to /home
|
||||
await browser.pause(3_000);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 3: Verify completion
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('mock server received the onboarding-complete call', async () => {
|
||||
const call = await waitForRequest('POST', '/telegram/settings/onboarding-complete');
|
||||
if (!call) {
|
||||
console.log('[LoginFlow] Request log:', JSON.stringify(getRequestLog(), null, 2));
|
||||
}
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
it('app navigated to Home page after onboarding', async () => {
|
||||
// Home page shows a greeting with the mock user's first name ("Test")
|
||||
const nameCandidates = [
|
||||
'Test',
|
||||
'Good morning',
|
||||
'Good afternoon',
|
||||
'Good evening',
|
||||
'Message AlphaHuman',
|
||||
'Upgrade to Premium',
|
||||
];
|
||||
|
||||
let foundText = null;
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const text of nameCandidates) {
|
||||
if (await textExists(text)) {
|
||||
foundText = text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundText) break;
|
||||
await browser.pause(1_000);
|
||||
}
|
||||
|
||||
if (foundText) {
|
||||
console.log(`[LoginFlow] Home page confirmed: found "${foundText}"`);
|
||||
} else {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[LoginFlow] Home page text not found. Tree:\n', tree.slice(0, 4000));
|
||||
}
|
||||
|
||||
expect(foundText).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import { elementExists, waitForApp } from "../helpers/app-helpers";
|
||||
import { elementExists, waitForApp } from '../helpers/app-helpers';
|
||||
|
||||
describe("Navigation", () => {
|
||||
describe('Navigation', () => {
|
||||
before(async () => {
|
||||
await waitForApp();
|
||||
});
|
||||
|
||||
it("app has menu items in the menu bar", async () => {
|
||||
it('app has menu items in the menu bar', async () => {
|
||||
// A running macOS app always has menu bar items
|
||||
const hasMenuItems = await elementExists("elementType == 56");
|
||||
const hasMenuItems = await elementExists('elementType == 56');
|
||||
// elementType 56 = XCUIElementTypeMenuBarItem
|
||||
expect(hasMenuItems).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { waitForApp } from "../helpers/app-helpers";
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
|
||||
describe("Smoke tests", () => {
|
||||
describe('Smoke tests', () => {
|
||||
before(async () => {
|
||||
await waitForApp();
|
||||
});
|
||||
|
||||
it("app process launched successfully (session created)", async () => {
|
||||
// If we get here, Appium created a session for the app — it's running
|
||||
expect(true).toBe(true);
|
||||
it('app process launched successfully (session created)', async () => {
|
||||
// Verify Appium has an active session connected to the app
|
||||
const sessionId = browser.sessionId;
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(typeof sessionId).toBe('string');
|
||||
expect(sessionId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("app has a menu bar", async () => {
|
||||
const menuBar = await browser.$("//XCUIElementTypeMenuBar");
|
||||
it('app has a menu bar', async () => {
|
||||
const menuBar = await browser.$('//XCUIElementTypeMenuBar');
|
||||
expect(await menuBar.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it("app accessibility tree has elements", async () => {
|
||||
it('app accessibility tree has elements', async () => {
|
||||
// Find any element in the app to confirm XCUITest can see it
|
||||
const elements = await browser.$$("//*");
|
||||
const elements = await browser.$$('//*');
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { waitForApp } from "../helpers/app-helpers";
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
|
||||
describe("Tauri app integration", () => {
|
||||
describe('Tauri app integration', () => {
|
||||
before(async () => {
|
||||
await waitForApp();
|
||||
});
|
||||
|
||||
it("app has a menu bar (macOS native integration)", async () => {
|
||||
const menuBar = await browser.$("//XCUIElementTypeMenuBar");
|
||||
it('app has a menu bar (macOS native integration)', async () => {
|
||||
const menuBar = await browser.$('//XCUIElementTypeMenuBar');
|
||||
expect(await menuBar.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it("app can take a screenshot (XCUITest bridge works)", async () => {
|
||||
it('app can take a screenshot (XCUITest bridge works)', async () => {
|
||||
const screenshot = await browser.takeScreenshot();
|
||||
expect(screenshot).toBeTruthy();
|
||||
expect(screenshot.length).toBeGreaterThan(100);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
@@ -50,7 +49,6 @@ export default defineConfig(async () => ({
|
||||
process: "process/browser",
|
||||
util: "util",
|
||||
os: "os-browserify/browser",
|
||||
"@alphahuman/skill-types": path.resolve(__dirname, "src/lib/skills/types.ts"),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
+4
-9
@@ -1,4 +1,3 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
|
||||
@@ -19,10 +18,6 @@ export default defineConfig({
|
||||
process: "process/browser",
|
||||
util: "util",
|
||||
os: "os-browserify/browser",
|
||||
"@alphahuman/skill-types": path.resolve(
|
||||
__dirname,
|
||||
"src/lib/skills/types.ts"
|
||||
),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
@@ -51,10 +46,10 @@ export default defineConfig({
|
||||
],
|
||||
reporter: ["text", "text-summary", "html", "lcov"],
|
||||
thresholds: {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 90,
|
||||
branches: 85,
|
||||
lines: 15,
|
||||
statements: 15,
|
||||
functions: 15,
|
||||
branches: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+22
-36
@@ -1,5 +1,5 @@
|
||||
import path from "path";
|
||||
import type { Options } from "@wdio/types";
|
||||
import type { Options } from '@wdio/types';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Resolve the path to the built Tauri application bundle.
|
||||
@@ -8,62 +8,48 @@ import type { Options } from "@wdio/types";
|
||||
* On Windows/Linux, tauri-driver would be used instead (not covered here).
|
||||
*/
|
||||
function getAppPath(): string {
|
||||
const base = path.resolve("src-tauri/target/debug/bundle");
|
||||
const base = path.resolve('src-tauri/target/debug/bundle');
|
||||
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return path.join(base, "macos", "AlphaHuman.app");
|
||||
case "win32":
|
||||
return path.join(
|
||||
"src-tauri",
|
||||
"target",
|
||||
"debug",
|
||||
"AlphaHuman.exe",
|
||||
);
|
||||
case "linux":
|
||||
return path.join(
|
||||
"src-tauri",
|
||||
"target",
|
||||
"debug",
|
||||
"alpha-human",
|
||||
);
|
||||
case 'darwin':
|
||||
return path.join(base, 'macos', 'AlphaHuman.app');
|
||||
case 'win32':
|
||||
return path.join('src-tauri', 'target', 'debug', 'AlphaHuman.exe');
|
||||
case 'linux':
|
||||
return path.join('src-tauri', 'target', 'debug', 'alpha-human');
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${process.platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const config: Options.Testrunner = {
|
||||
runner: "local",
|
||||
hostname: "127.0.0.1",
|
||||
runner: 'local',
|
||||
hostname: '127.0.0.1',
|
||||
port: 4723, // Appium default port
|
||||
specs: ["./test/e2e/specs/**/*.spec.ts"],
|
||||
specs: ['./test/e2e/specs/**/*.spec.ts'],
|
||||
maxInstances: 1, // Tauri apps are single-instance
|
||||
capabilities: [
|
||||
{
|
||||
platformName: "mac",
|
||||
platformName: 'mac',
|
||||
// @ts-expect-error -- Appium capabilities are not in standard WebDriver types
|
||||
"appium:automationName": "Mac2",
|
||||
"appium:app": getAppPath(),
|
||||
"appium:bundleId": "com.alphahuman.app",
|
||||
"appium:showServerLogs": true,
|
||||
'appium:automationName': 'Mac2',
|
||||
'appium:app': getAppPath(),
|
||||
'appium:bundleId': 'com.alphahuman.app',
|
||||
'appium:showServerLogs': true,
|
||||
},
|
||||
],
|
||||
logLevel: "warn",
|
||||
logLevel: 'warn',
|
||||
bail: 0,
|
||||
waitforTimeout: 10_000,
|
||||
connectionRetryTimeout: 120_000,
|
||||
connectionRetryCount: 3,
|
||||
// No appium service — appium is started externally via scripts/start-appium.sh
|
||||
// so we can control the Node version (appium v3 requires Node >=24).
|
||||
framework: "mocha",
|
||||
reporters: ["spec"],
|
||||
framework: 'mocha',
|
||||
reporters: ['spec'],
|
||||
mochaOpts: {
|
||||
ui: "bdd",
|
||||
ui: 'bdd',
|
||||
timeout: 60_000, // App startup can be slow
|
||||
},
|
||||
autoCompileOpts: {
|
||||
tsNodeOpts: {
|
||||
project: "./tsconfig.e2e.json",
|
||||
},
|
||||
},
|
||||
autoCompileOpts: { tsNodeOpts: { project: './tsconfig.e2e.json' } },
|
||||
};
|
||||
|
||||
@@ -1458,7 +1458,7 @@
|
||||
"@types/deep-eql" "*"
|
||||
assertion-error "^2.0.1"
|
||||
|
||||
"@types/debug@^4.1.12":
|
||||
"@types/debug@^4.0.0", "@types/debug@^4.1.12":
|
||||
version "4.1.12"
|
||||
resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz"
|
||||
integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==
|
||||
@@ -1470,11 +1470,25 @@
|
||||
resolved "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz"
|
||||
integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==
|
||||
|
||||
"@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6":
|
||||
"@types/estree-jsx@^1.0.0":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz#858a88ea20f34fe65111f005a689fa1ebf70dc18"
|
||||
integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==
|
||||
dependencies:
|
||||
"@types/estree" "*"
|
||||
|
||||
"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
|
||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
||||
|
||||
"@types/hast@^3.0.0":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa"
|
||||
integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==
|
||||
dependencies:
|
||||
"@types/unist" "*"
|
||||
|
||||
"@types/history@^4.7.11":
|
||||
version "4.7.11"
|
||||
resolved "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz"
|
||||
@@ -1509,6 +1523,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||
|
||||
"@types/mdast@^4.0.0":
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6"
|
||||
integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==
|
||||
dependencies:
|
||||
"@types/unist" "*"
|
||||
|
||||
"@types/mocha@^10.0.6":
|
||||
version "10.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.10.tgz#91f62905e8d23cbd66225312f239454a23bebfa0"
|
||||
@@ -1596,6 +1617,16 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/statuses/-/statuses-2.0.6.tgz#66748315cc9a96d63403baa8671b2c124f8633aa"
|
||||
integrity sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==
|
||||
|
||||
"@types/unist@*", "@types/unist@^3.0.0":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
|
||||
integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
|
||||
|
||||
"@types/unist@^2.0.0":
|
||||
version "2.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4"
|
||||
integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==
|
||||
|
||||
"@types/use-sync-external-store@^0.0.6":
|
||||
version "0.0.6"
|
||||
resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz"
|
||||
@@ -1728,6 +1759,11 @@
|
||||
"@typescript-eslint/types" "8.54.0"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
|
||||
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
|
||||
|
||||
"@vitejs/plugin-react@^4.6.0":
|
||||
version "4.7.0"
|
||||
resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz"
|
||||
@@ -2336,6 +2372,11 @@ b4a@^1.6.4:
|
||||
resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.7.3.tgz#24cf7ccda28f5465b66aec2bac69e32809bf112f"
|
||||
integrity sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==
|
||||
|
||||
bail@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d"
|
||||
integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==
|
||||
|
||||
balanced-match@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
@@ -2631,6 +2672,11 @@ caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760:
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz"
|
||||
integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
||||
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
||||
|
||||
chai@^6.2.1:
|
||||
version "6.2.2"
|
||||
resolved "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz"
|
||||
@@ -2654,6 +2700,26 @@ change-case@^5.4.3:
|
||||
resolved "https://registry.yarnpkg.com/change-case/-/change-case-5.4.4.tgz#0d52b507d8fb8f204343432381d1a6d7bff97a02"
|
||||
integrity sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==
|
||||
|
||||
character-entities-html4@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b"
|
||||
integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==
|
||||
|
||||
character-entities-legacy@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b"
|
||||
integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==
|
||||
|
||||
character-entities@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22"
|
||||
integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
|
||||
|
||||
character-reference-invalid@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9"
|
||||
integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
|
||||
|
||||
chardet@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/chardet/-/chardet-2.1.1.tgz#5c75593704a642f71ee53717df234031e65373c8"
|
||||
@@ -2764,6 +2830,11 @@ color-name@~1.1.4:
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
comma-separated-tokens@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
|
||||
integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
|
||||
|
||||
commander@^14.0.0:
|
||||
version "14.0.3"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2"
|
||||
@@ -3022,7 +3093,7 @@ data-view-byte-offset@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
is-data-view "^1.0.1"
|
||||
|
||||
debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.3, debug@~4.4.1:
|
||||
debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.3, debug@~4.4.1:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
@@ -3058,6 +3129,13 @@ decimal.js@^10.6.0:
|
||||
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a"
|
||||
integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==
|
||||
|
||||
decode-named-character-reference@^1.0.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz#3e40603760874c2e5867691b599d73a7da25b53f"
|
||||
integrity sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==
|
||||
dependencies:
|
||||
character-entities "^2.0.0"
|
||||
|
||||
deep-diff@^0.3.5:
|
||||
version "0.3.8"
|
||||
resolved "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz"
|
||||
@@ -3112,7 +3190,7 @@ degenerator@^5.0.0:
|
||||
escodegen "^2.1.0"
|
||||
esprima "^4.0.1"
|
||||
|
||||
dequal@^2.0.3:
|
||||
dequal@^2.0.0, dequal@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
|
||||
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
|
||||
@@ -3125,6 +3203,13 @@ des.js@^1.0.0:
|
||||
inherits "^2.0.1"
|
||||
minimalistic-assert "^1.0.0"
|
||||
|
||||
devlop@^1.0.0, devlop@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018"
|
||||
integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==
|
||||
dependencies:
|
||||
dequal "^2.0.0"
|
||||
|
||||
didyoumean@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
|
||||
@@ -3803,6 +3888,11 @@ estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0:
|
||||
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
|
||||
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
|
||||
|
||||
estree-util-is-identifier-name@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd"
|
||||
integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==
|
||||
|
||||
estree-walker@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz"
|
||||
@@ -3910,6 +4000,11 @@ ext@^1.7.0:
|
||||
dependencies:
|
||||
type "^2.7.2"
|
||||
|
||||
extend@^3.0.0:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
|
||||
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
|
||||
|
||||
extract-zip@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
|
||||
@@ -4332,6 +4427,34 @@ hasown@^2.0.2:
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
hast-util-to-jsx-runtime@^2.0.0:
|
||||
version "2.3.6"
|
||||
resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz#ff31897aae59f62232e21594eac7ef6b63333e98"
|
||||
integrity sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.0"
|
||||
"@types/hast" "^3.0.0"
|
||||
"@types/unist" "^3.0.0"
|
||||
comma-separated-tokens "^2.0.0"
|
||||
devlop "^1.0.0"
|
||||
estree-util-is-identifier-name "^3.0.0"
|
||||
hast-util-whitespace "^3.0.0"
|
||||
mdast-util-mdx-expression "^2.0.0"
|
||||
mdast-util-mdx-jsx "^3.0.0"
|
||||
mdast-util-mdxjs-esm "^2.0.0"
|
||||
property-information "^7.0.0"
|
||||
space-separated-tokens "^2.0.0"
|
||||
style-to-js "^1.0.0"
|
||||
unist-util-position "^5.0.0"
|
||||
vfile-message "^4.0.0"
|
||||
|
||||
hast-util-whitespace@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621"
|
||||
integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==
|
||||
dependencies:
|
||||
"@types/hast" "^3.0.0"
|
||||
|
||||
he@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
|
||||
@@ -4389,6 +4512,11 @@ html-escaper@^2.0.0:
|
||||
resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz"
|
||||
integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
|
||||
|
||||
html-url-attributes@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87"
|
||||
integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==
|
||||
|
||||
htmlfy@^0.8.1:
|
||||
version "0.8.1"
|
||||
resolved "https://registry.yarnpkg.com/htmlfy/-/htmlfy-0.8.1.tgz#9066e318a2ee79d7ab2ef83c3953b02a18fa2b14"
|
||||
@@ -4520,6 +4648,11 @@ inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3,
|
||||
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
||||
inline-style-parser@0.2.7:
|
||||
version "0.2.7"
|
||||
resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz#b1fc68bfc0313b8685745e4464e37f9376b9c909"
|
||||
integrity sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==
|
||||
|
||||
inquirer@^12.7.0:
|
||||
version "12.11.1"
|
||||
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-12.11.1.tgz#3f5770b9ec926b0909e463e42c766f2664f2cc96"
|
||||
@@ -4547,6 +4680,19 @@ ip-address@^10.0.1:
|
||||
resolved "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz"
|
||||
integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==
|
||||
|
||||
is-alphabetical@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz#01072053ea7c1036df3c7d19a6daaec7f19e789b"
|
||||
integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==
|
||||
|
||||
is-alphanumerical@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz#7c03fbe96e3e931113e57f964b0a368cc2dfd875"
|
||||
integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==
|
||||
dependencies:
|
||||
is-alphabetical "^2.0.0"
|
||||
is-decimal "^2.0.0"
|
||||
|
||||
is-arguments@^1.0.4:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz"
|
||||
@@ -4631,6 +4777,11 @@ is-date-object@^1.0.5, is-date-object@^1.1.0:
|
||||
call-bound "^1.0.2"
|
||||
has-tostringtag "^1.0.2"
|
||||
|
||||
is-decimal@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7"
|
||||
integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==
|
||||
|
||||
is-extglob@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
|
||||
@@ -4666,6 +4817,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
||||
dependencies:
|
||||
is-extglob "^2.1.1"
|
||||
|
||||
is-hexadecimal@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027"
|
||||
integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==
|
||||
|
||||
is-map@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e"
|
||||
@@ -4707,7 +4863,7 @@ is-plain-obj@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
|
||||
integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
|
||||
|
||||
is-plain-obj@^4.1.0:
|
||||
is-plain-obj@^4.0.0, is-plain-obj@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0"
|
||||
integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==
|
||||
@@ -5186,6 +5342,11 @@ loglevel@^1.6.0:
|
||||
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08"
|
||||
integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==
|
||||
|
||||
longest-streak@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4"
|
||||
integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==
|
||||
|
||||
loose-envify@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||
@@ -5269,6 +5430,111 @@ md5.js@^1.3.4:
|
||||
inherits "^2.0.1"
|
||||
safe-buffer "^5.1.2"
|
||||
|
||||
mdast-util-from-markdown@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a"
|
||||
integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
"@types/unist" "^3.0.0"
|
||||
decode-named-character-reference "^1.0.0"
|
||||
devlop "^1.0.0"
|
||||
mdast-util-to-string "^4.0.0"
|
||||
micromark "^4.0.0"
|
||||
micromark-util-decode-numeric-character-reference "^2.0.0"
|
||||
micromark-util-decode-string "^2.0.0"
|
||||
micromark-util-normalize-identifier "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
unist-util-stringify-position "^4.0.0"
|
||||
|
||||
mdast-util-mdx-expression@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz#43f0abac9adc756e2086f63822a38c8d3c3a5096"
|
||||
integrity sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==
|
||||
dependencies:
|
||||
"@types/estree-jsx" "^1.0.0"
|
||||
"@types/hast" "^3.0.0"
|
||||
"@types/mdast" "^4.0.0"
|
||||
devlop "^1.0.0"
|
||||
mdast-util-from-markdown "^2.0.0"
|
||||
mdast-util-to-markdown "^2.0.0"
|
||||
|
||||
mdast-util-mdx-jsx@^3.0.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz#fd04c67a2a7499efb905a8a5c578dddc9fdada0d"
|
||||
integrity sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==
|
||||
dependencies:
|
||||
"@types/estree-jsx" "^1.0.0"
|
||||
"@types/hast" "^3.0.0"
|
||||
"@types/mdast" "^4.0.0"
|
||||
"@types/unist" "^3.0.0"
|
||||
ccount "^2.0.0"
|
||||
devlop "^1.1.0"
|
||||
mdast-util-from-markdown "^2.0.0"
|
||||
mdast-util-to-markdown "^2.0.0"
|
||||
parse-entities "^4.0.0"
|
||||
stringify-entities "^4.0.0"
|
||||
unist-util-stringify-position "^4.0.0"
|
||||
vfile-message "^4.0.0"
|
||||
|
||||
mdast-util-mdxjs-esm@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz#019cfbe757ad62dd557db35a695e7314bcc9fa97"
|
||||
integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==
|
||||
dependencies:
|
||||
"@types/estree-jsx" "^1.0.0"
|
||||
"@types/hast" "^3.0.0"
|
||||
"@types/mdast" "^4.0.0"
|
||||
devlop "^1.0.0"
|
||||
mdast-util-from-markdown "^2.0.0"
|
||||
mdast-util-to-markdown "^2.0.0"
|
||||
|
||||
mdast-util-phrasing@^4.0.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3"
|
||||
integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
unist-util-is "^6.0.0"
|
||||
|
||||
mdast-util-to-hast@^13.0.0:
|
||||
version "13.2.1"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz#d7ff84ca499a57e2c060ae67548ad950e689a053"
|
||||
integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==
|
||||
dependencies:
|
||||
"@types/hast" "^3.0.0"
|
||||
"@types/mdast" "^4.0.0"
|
||||
"@ungap/structured-clone" "^1.0.0"
|
||||
devlop "^1.0.0"
|
||||
micromark-util-sanitize-uri "^2.0.0"
|
||||
trim-lines "^3.0.0"
|
||||
unist-util-position "^5.0.0"
|
||||
unist-util-visit "^5.0.0"
|
||||
vfile "^6.0.0"
|
||||
|
||||
mdast-util-to-markdown@^2.0.0:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b"
|
||||
integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
"@types/unist" "^3.0.0"
|
||||
longest-streak "^3.0.0"
|
||||
mdast-util-phrasing "^4.0.0"
|
||||
mdast-util-to-string "^4.0.0"
|
||||
micromark-util-classify-character "^2.0.0"
|
||||
micromark-util-decode-string "^2.0.0"
|
||||
unist-util-visit "^5.0.0"
|
||||
zwitch "^2.0.0"
|
||||
|
||||
mdast-util-to-string@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814"
|
||||
integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
|
||||
mdn-data@2.12.2:
|
||||
version "2.12.2"
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.12.2.tgz#9ae6c41a9e65adf61318b32bff7b64fbfb13f8cf"
|
||||
@@ -5279,6 +5545,200 @@ merge2@^1.3.0:
|
||||
resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
|
||||
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||
|
||||
micromark-core-commonmark@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4"
|
||||
integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==
|
||||
dependencies:
|
||||
decode-named-character-reference "^1.0.0"
|
||||
devlop "^1.0.0"
|
||||
micromark-factory-destination "^2.0.0"
|
||||
micromark-factory-label "^2.0.0"
|
||||
micromark-factory-space "^2.0.0"
|
||||
micromark-factory-title "^2.0.0"
|
||||
micromark-factory-whitespace "^2.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-chunked "^2.0.0"
|
||||
micromark-util-classify-character "^2.0.0"
|
||||
micromark-util-html-tag-name "^2.0.0"
|
||||
micromark-util-normalize-identifier "^2.0.0"
|
||||
micromark-util-resolve-all "^2.0.0"
|
||||
micromark-util-subtokenize "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-factory-destination@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639"
|
||||
integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==
|
||||
dependencies:
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-factory-label@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1"
|
||||
integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==
|
||||
dependencies:
|
||||
devlop "^1.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-factory-space@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc"
|
||||
integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==
|
||||
dependencies:
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-factory-title@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94"
|
||||
integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==
|
||||
dependencies:
|
||||
micromark-factory-space "^2.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-factory-whitespace@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1"
|
||||
integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==
|
||||
dependencies:
|
||||
micromark-factory-space "^2.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-util-character@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6"
|
||||
integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==
|
||||
dependencies:
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-util-chunked@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051"
|
||||
integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==
|
||||
dependencies:
|
||||
micromark-util-symbol "^2.0.0"
|
||||
|
||||
micromark-util-classify-character@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629"
|
||||
integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==
|
||||
dependencies:
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-util-combine-extensions@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9"
|
||||
integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==
|
||||
dependencies:
|
||||
micromark-util-chunked "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-util-decode-numeric-character-reference@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5"
|
||||
integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==
|
||||
dependencies:
|
||||
micromark-util-symbol "^2.0.0"
|
||||
|
||||
micromark-util-decode-string@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2"
|
||||
integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==
|
||||
dependencies:
|
||||
decode-named-character-reference "^1.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-decode-numeric-character-reference "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
|
||||
micromark-util-encode@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8"
|
||||
integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==
|
||||
|
||||
micromark-util-html-tag-name@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825"
|
||||
integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==
|
||||
|
||||
micromark-util-normalize-identifier@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d"
|
||||
integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==
|
||||
dependencies:
|
||||
micromark-util-symbol "^2.0.0"
|
||||
|
||||
micromark-util-resolve-all@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b"
|
||||
integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==
|
||||
dependencies:
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-util-sanitize-uri@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7"
|
||||
integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==
|
||||
dependencies:
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-encode "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
|
||||
micromark-util-subtokenize@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee"
|
||||
integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==
|
||||
dependencies:
|
||||
devlop "^1.0.0"
|
||||
micromark-util-chunked "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-util-symbol@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8"
|
||||
integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==
|
||||
|
||||
micromark-util-types@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e"
|
||||
integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==
|
||||
|
||||
micromark@^4.0.0:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb"
|
||||
integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==
|
||||
dependencies:
|
||||
"@types/debug" "^4.0.0"
|
||||
debug "^4.0.0"
|
||||
decode-named-character-reference "^1.0.0"
|
||||
devlop "^1.0.0"
|
||||
micromark-core-commonmark "^2.0.0"
|
||||
micromark-factory-space "^2.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-chunked "^2.0.0"
|
||||
micromark-util-combine-extensions "^2.0.0"
|
||||
micromark-util-decode-numeric-character-reference "^2.0.0"
|
||||
micromark-util-encode "^2.0.0"
|
||||
micromark-util-normalize-identifier "^2.0.0"
|
||||
micromark-util-resolve-all "^2.0.0"
|
||||
micromark-util-sanitize-uri "^2.0.0"
|
||||
micromark-util-subtokenize "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromatch@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz"
|
||||
@@ -5748,6 +6208,19 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.9:
|
||||
pbkdf2 "^3.1.5"
|
||||
safe-buffer "^5.2.1"
|
||||
|
||||
parse-entities@^4.0.0:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-4.0.2.tgz#61d46f5ed28e4ee62e9ddc43d6b010188443f159"
|
||||
integrity sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==
|
||||
dependencies:
|
||||
"@types/unist" "^2.0.0"
|
||||
character-entities-legacy "^3.0.0"
|
||||
character-reference-invalid "^2.0.0"
|
||||
decode-named-character-reference "^1.0.0"
|
||||
is-alphanumerical "^2.0.0"
|
||||
is-decimal "^2.0.0"
|
||||
is-hexadecimal "^2.0.0"
|
||||
|
||||
parse-imports-exports@^0.2.4:
|
||||
version "0.2.4"
|
||||
resolved "https://registry.yarnpkg.com/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz#e3fb3b5e264cfb55c25b5dfcbe7f410f8dc4e7af"
|
||||
@@ -6031,6 +6504,11 @@ prop-types@^15.8.1:
|
||||
object-assign "^4.1.1"
|
||||
react-is "^16.13.1"
|
||||
|
||||
property-information@^7.0.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d"
|
||||
integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==
|
||||
|
||||
proxy-agent@^6.5.0:
|
||||
version "6.5.0"
|
||||
resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-6.5.0.tgz#9e49acba8e4ee234aacb539f89ed9c23d02f232d"
|
||||
@@ -6144,6 +6622,23 @@ react-is@^18.3.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-markdown@^10.1.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca"
|
||||
integrity sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==
|
||||
dependencies:
|
||||
"@types/hast" "^3.0.0"
|
||||
"@types/mdast" "^4.0.0"
|
||||
devlop "^1.0.0"
|
||||
hast-util-to-jsx-runtime "^2.0.0"
|
||||
html-url-attributes "^3.0.0"
|
||||
mdast-util-to-hast "^13.0.0"
|
||||
remark-parse "^11.0.0"
|
||||
remark-rehype "^11.0.0"
|
||||
unified "^11.0.0"
|
||||
unist-util-visit "^5.0.0"
|
||||
vfile "^6.0.0"
|
||||
|
||||
react-redux@^9.2.0:
|
||||
version "9.2.0"
|
||||
resolved "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz"
|
||||
@@ -6323,6 +6818,27 @@ regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4:
|
||||
gopd "^1.2.0"
|
||||
set-function-name "^2.0.2"
|
||||
|
||||
remark-parse@^11.0.0:
|
||||
version "11.0.0"
|
||||
resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1"
|
||||
integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
mdast-util-from-markdown "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
unified "^11.0.0"
|
||||
|
||||
remark-rehype@^11.0.0:
|
||||
version "11.1.2"
|
||||
resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.1.2.tgz#2addaadda80ca9bd9aa0da763e74d16327683b37"
|
||||
integrity sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==
|
||||
dependencies:
|
||||
"@types/hast" "^3.0.0"
|
||||
"@types/mdast" "^4.0.0"
|
||||
mdast-util-to-hast "^13.0.0"
|
||||
unified "^11.0.0"
|
||||
vfile "^6.0.0"
|
||||
|
||||
require-directory@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
||||
@@ -6722,6 +7238,11 @@ source-map@~0.6.1:
|
||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
||||
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||
|
||||
space-separated-tokens@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f"
|
||||
integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==
|
||||
|
||||
spacetrim@0.11.59:
|
||||
version "0.11.59"
|
||||
resolved "https://registry.yarnpkg.com/spacetrim/-/spacetrim-0.11.59.tgz#b9cf378b5d1ab9ed729d4c7c3e4412ec00a187b9"
|
||||
@@ -6830,16 +7351,7 @@ strict-event-emitter@^0.5.1:
|
||||
resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93"
|
||||
integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -6930,14 +7442,15 @@ string_decoder@~1.1.1:
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
stringify-entities@^4.0.0:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3"
|
||||
integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
character-entities-html4 "^2.0.0"
|
||||
character-entities-legacy "^3.0.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -6978,6 +7491,20 @@ strnum@^2.1.2:
|
||||
resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.1.2.tgz#a5e00ba66ab25f9cafa3726b567ce7a49170937a"
|
||||
integrity sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==
|
||||
|
||||
style-to-js@^1.0.0:
|
||||
version "1.1.21"
|
||||
resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.21.tgz#2908941187f857e79e28e9cd78008b9a0b3e0e8d"
|
||||
integrity sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==
|
||||
dependencies:
|
||||
style-to-object "1.0.14"
|
||||
|
||||
style-to-object@1.0.14:
|
||||
version "1.0.14"
|
||||
resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.14.tgz#1d22f0e7266bb8c6d8cae5caf4ec4f005e08f611"
|
||||
integrity sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==
|
||||
dependencies:
|
||||
inline-style-parser "0.2.7"
|
||||
|
||||
sucrase@^3.35.0:
|
||||
version "3.35.1"
|
||||
resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz"
|
||||
@@ -7194,6 +7721,16 @@ tree-kill@^1.2.2:
|
||||
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
|
||||
integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
|
||||
|
||||
trim-lines@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338"
|
||||
integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==
|
||||
|
||||
trough@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f"
|
||||
integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==
|
||||
|
||||
ts-api-utils@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8"
|
||||
@@ -7365,6 +7902,57 @@ unicorn-magic@^0.3.0:
|
||||
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz#4efd45c85a69e0dd576d25532fbfa22aa5c8a104"
|
||||
integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==
|
||||
|
||||
unified@^11.0.0:
|
||||
version "11.0.5"
|
||||
resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1"
|
||||
integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==
|
||||
dependencies:
|
||||
"@types/unist" "^3.0.0"
|
||||
bail "^2.0.0"
|
||||
devlop "^1.0.0"
|
||||
extend "^3.0.0"
|
||||
is-plain-obj "^4.0.0"
|
||||
trough "^2.0.0"
|
||||
vfile "^6.0.0"
|
||||
|
||||
unist-util-is@^6.0.0:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.1.tgz#d0a3f86f2dd0db7acd7d8c2478080b5c67f9c6a9"
|
||||
integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==
|
||||
dependencies:
|
||||
"@types/unist" "^3.0.0"
|
||||
|
||||
unist-util-position@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4"
|
||||
integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==
|
||||
dependencies:
|
||||
"@types/unist" "^3.0.0"
|
||||
|
||||
unist-util-stringify-position@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2"
|
||||
integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==
|
||||
dependencies:
|
||||
"@types/unist" "^3.0.0"
|
||||
|
||||
unist-util-visit-parents@^6.0.0:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz#777df7fb98652ce16b4b7cd999d0a1a40efa3a02"
|
||||
integrity sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==
|
||||
dependencies:
|
||||
"@types/unist" "^3.0.0"
|
||||
unist-util-is "^6.0.0"
|
||||
|
||||
unist-util-visit@^5.0.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz#9a2a28b0aa76a15e0da70a08a5863a2f060e2468"
|
||||
integrity sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==
|
||||
dependencies:
|
||||
"@types/unist" "^3.0.0"
|
||||
unist-util-is "^6.0.0"
|
||||
unist-util-visit-parents "^6.0.0"
|
||||
|
||||
until-async@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/until-async/-/until-async-3.0.2.tgz#447f1531fdd7bb2b4c7a98869bdb1a4c2a23865f"
|
||||
@@ -7439,6 +8027,22 @@ validate-npm-package-license@^3.0.4:
|
||||
spdx-correct "^3.0.0"
|
||||
spdx-expression-parse "^3.0.0"
|
||||
|
||||
vfile-message@^4.0.0:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.3.tgz#87b44dddd7b70f0641c2e3ed0864ba73e2ea8df4"
|
||||
integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==
|
||||
dependencies:
|
||||
"@types/unist" "^3.0.0"
|
||||
unist-util-stringify-position "^4.0.0"
|
||||
|
||||
vfile@^6.0.0:
|
||||
version "6.0.3"
|
||||
resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab"
|
||||
integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==
|
||||
dependencies:
|
||||
"@types/unist" "^3.0.0"
|
||||
vfile-message "^4.0.0"
|
||||
|
||||
vite-plugin-node-polyfills@^0.25.0:
|
||||
version "0.25.0"
|
||||
resolved "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.25.0.tgz"
|
||||
@@ -7691,7 +8295,7 @@ workerpool@^6.5.1:
|
||||
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544"
|
||||
integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@@ -7709,15 +8313,6 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
@@ -7888,3 +8483,8 @@ zustand@^5.0.10:
|
||||
version "5.0.10"
|
||||
resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz"
|
||||
integrity sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==
|
||||
|
||||
zwitch@^2.0.0:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
|
||||
integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==
|
||||
|
||||
Reference in New Issue
Block a user