commit fd549c6e3d9da83af79b492066e06724dbccf901 Author: zyh Date: Sun Mar 29 23:14:09 2026 +0000 feat: 初始化环信 SDK 测试项目 - 添加项目配置文档 (AGENTS.md) - 创建 package.json,包含环信 SDK 及测试依赖 - 添加环信 polyfill 工具模块 - 添加双用户消息互发测试脚本 - 添加 polyfill 测试脚本 Co-Authored-By: Claude Co-Authored-By: Happy diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1a56af7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,206 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and Development Commands + +```bash +# Development - starts Vite dev server (port 5175) + Electron app with hot reload +npm run electron:dev + +# Build production bundle (TypeScript + Vite) +npm run build + +# Lint with ESLint +npm run lint + +# Run memory extractor tests (Node.js built-in test runner) +npm run test:memory + +# Compile Electron main process only +npm run compile:electron + +# Package for distribution (platform-specific) +npm run dist:mac # macOS (.dmg) +npm run dist:win # Windows (.exe) +npm run dist:linux # Linux (.AppImage) +``` + +**Requirements**: Node.js >=24 <25. Windows builds require PortableGit (see README.md for setup). + +## Architecture Overview + +OriginClawAI is an Electron + React desktop application with two primary modes: +1. **Cowork Mode** - AI-assisted coding sessions using Claude Agent SDK with tool execution +2. **Artifacts System** - Rich preview of code outputs (HTML, SVG, React, Mermaid) + +Uses strict process isolation with IPC communication. + +### Process Model + +**Main Process** (`src/main/main.ts`): +- Window lifecycle management +- SQLite storage via `sql.js` (`src/main/sqliteStore.ts`) +- Cowork session runner (`src/main/libs/coworkRunner.ts`) - executes Claude Agent SDK +- IPC handlers for store, cowork, and API operations +- Security: context isolation enabled, node integration disabled, sandbox enabled + +**Preload Script** (`src/main/preload.ts`): +- Exposes `window.electron` API via `contextBridge` +- Includes `cowork` namespace for session management and streaming events + +**Renderer Process** (React in `src/renderer/`): +- All UI and business logic +- Communicates with main process exclusively through IPC + +### Key Directories + +``` +src/main/ +├── main.ts # Entry point, IPC handlers +├── sqliteStore.ts # SQLite database (kv + cowork tables) +├── coworkStore.ts # Cowork session/message CRUD operations +└── libs/ + ├── coworkRunner.ts # Claude Agent SDK execution engine + ├── coworkVmRunner.ts # Sandbox VM execution mode + ├── claudeSdk.ts # SDK loader utilities + ├── coworkMemoryExtractor.ts # Extracts memory changes from conversations + └── coworkMemoryJudge.ts # Validates memory candidates with scoring/LLM + +src/renderer/ +├── types/cowork.ts # Cowork type definitions +├── store/slices/ +│ ├── coworkSlice.ts # Cowork sessions and streaming state +│ └── artifactSlice.ts # Artifacts state +├── services/ +│ ├── cowork.ts # Cowork service (IPC wrapper, Redux integration) +│ ├── api.ts # LLM API with SSE streaming +│ └── artifactParser.ts # Artifact detection and parsing +├── components/ +│ ├── cowork/ # Cowork UI components +│ │ ├── CoworkView.tsx # Main cowork interface +│ │ ├── CoworkSessionList.tsx # Session sidebar +│ │ ├── CoworkSessionDetail.tsx # Message display +│ │ └── CoworkPermissionModal.tsx # Tool permission UI +│ └── artifacts/ # Artifact renderers + +SKILLs/ # Custom skill definitions for cowork sessions +├── skills.config.json # Skill enable/order configuration +├── docx/ # Word document generation skill +├── xlsx/ # Excel skill +├── pptx/ # PowerPoint skill +└── ... +``` + +### Data Flow + +1. **Initialization**: `src/renderer/App.tsx` → `coworkService.init()` → loads config/sessions via IPC → sets up stream listeners +2. **Cowork Session**: User sends prompt → `coworkService.startSession()` → IPC to main → `CoworkRunner.startSession()` → Claude Agent SDK execution → streaming events back to renderer via IPC → Redux updates +3. **Tool Permissions**: Claude requests tool use → `CoworkRunner` emits `permissionRequest` → UI shows `CoworkPermissionModal` → user approves/denies → result sent back to SDK +4. **Persistence**: Cowork sessions stored in SQLite (`cowork_sessions`, `cowork_messages` tables) + +### Cowork System + +The Cowork feature provides AI-assisted coding sessions: + +**Execution Modes** (`CoworkExecutionMode`): +- `auto` - Automatically choose based on context +- `local` - Run tools directly on the local machine +- `sandbox` - Run tools in isolated VM environment + +**Memory System**: Automatically extracts and manages user memories from conversations: +- `coworkMemoryExtractor.ts` - Detects explicit remember/forget commands (Chinese/English) and implicitly extracts personal facts using signal patterns (profile, preferences, ownership). Uses guard levels (`strict`/`standard`/`relaxed`) with confidence thresholds. +- `coworkMemoryJudge.ts` - Validates memory candidates with rule-based scoring and optional LLM secondary judgment for borderline cases. Includes TTL-based caching for LLM results. + +**Stream Events** (IPC from main to renderer): +- `message` - New message added to session +- `messageUpdate` - Streaming content update for existing message +- `permissionRequest` - Tool needs user approval +- `complete` - Session execution finished +- `error` - Session encountered an error + +**Key IPC Channels**: +- `cowork:startSession`, `cowork:continueSession`, `cowork:stopSession` +- `cowork:getSession`, `cowork:listSessions`, `cowork:deleteSession` +- `cowork:respondToPermission`, `cowork:getConfig`, `cowork:setConfig` + +### Key Patterns + +- **Streaming responses**: `apiService.chat()` uses SSE with `onProgress` callback for real-time message updates +- **Cowork streaming**: Uses IPC event listeners (`onStreamMessage`, `onStreamMessageUpdate`, etc.) for bidirectional communication +- **Markdown rendering**: `react-markdown` with `remark-gfm`, `remark-math`, `rehype-katex` for GitHub markdown and LaTeX +- **Theme system**: Class-based Tailwind dark mode, applies `dark` class to `` element +- **i18n**: Simple key-value translation in `services/i18n.ts`, supports Chinese (default) and English. Language auto-detected from system locale on first run. +- **Path alias**: `@` maps to `src/renderer/` in Vite config for imports. +- **Skills**: Custom skill definitions in `SKILLs/` directory, configured via `skills.config.json` + +### Artifacts System + +The Artifacts feature provides rich preview of code outputs similar to Claude's artifacts: + +**Supported Types**: +- `html` - Full HTML pages rendered in sandboxed iframe +- `svg` - SVG graphics with DOMPurify sanitization and zoom controls +- `mermaid` - Flowcharts, sequence diagrams, class diagrams via Mermaid.js +- `react` - React/JSX components compiled with Babel in isolated iframe +- `code` - Syntax highlighted code with line numbers + +**Detection Methods**: +1. Explicit markers: ` ```artifact:html title="My Page" ` +2. Heuristic detection: Analyzes code block language and content patterns + +**UI Components**: +- Right-side panel (300-800px resizable width) +- Header with type icon, title, copy/download/close buttons +- Artifact badges in messages to switch between artifacts + +**Security**: +- HTML: `sandbox="allow-scripts"` with no `allow-same-origin` +- SVG: DOMPurify removes all script content +- React: Completely isolated iframe with no network access +- Mermaid: `securityLevel: 'strict'` configuration + +### Configuration + +- App config stored in SQLite `kv` table +- Cowork config stored in `cowork_config` table (workingDirectory, systemPrompt, executionMode) +- Cowork sessions and messages stored in `cowork_sessions` and `cowork_messages` tables +- Database file: `originclawai.sqlite` in user data directory + +### TypeScript Configuration + +- `tsconfig.json`: React/renderer code (ES2020, ESNext modules) +- `electron-tsconfig.json`: Electron main process (CommonJS output to `dist-electron/`) + +### Key Dependencies + +- `@anthropic-ai/claude-agent-sdk` - Claude Agent SDK for cowork sessions +- `sql.js` - SQLite database for persistence +- `react-markdown`, `remark-gfm`, `rehype-katex` - Markdown rendering with math support +- `mermaid` - Diagram rendering +- `dompurify` - SVG/HTML sanitization + +## Coding Style & Naming Conventions + +- Use TypeScript, functional React components, and Hooks; keep logic in `src/renderer/services/` when it is not UI-specific. +- Match existing formatting: 2-space indentation, single quotes, and semicolons. +- Naming: `PascalCase` for components (e.g., `Chat.tsx`), `camelCase` for functions/vars, and `*Slice.ts` for Redux slices. +- Tailwind CSS is the primary styling approach; prefer utility classes over bespoke CSS. + +## Testing Guidelines + +- Tests use Node.js built-in `node:test` module (no Jest/Mocha/Vitest). +- Run tests: `npm run test:memory` (compiles Electron main process first, then runs `tests/coworkMemoryExtractor.test.mjs`). +- Test files live in `tests/` directory and import compiled output from `dist-electron/`. +- Validate UI changes manually by running `npm run electron:dev` and exercising key flows: + - Cowork: start session, send prompts, approve/deny tool permissions, stop session + - Artifacts: preview HTML, SVG, Mermaid diagrams, React components + - Settings: theme switching, language switching +- Keep console warnings/errors clean; lint via `npm run lint` before submitting. + +## Commit & Pull Request Guidelines + +- Recent history uses conventional prefixes like `feat:`, `refactor:`, and `chore:`; older commits include `feature:` and `Initial commit`. +- Prefer `type: short imperative summary` (e.g., `feat: add artifact toolbar actions`). +- PRs should include a concise description, linked issue if applicable, and screenshots for UI changes. +- Call out any Electron-specific behavior changes (IPC, storage, windowing) in the PR description. diff --git a/package.json b/package.json new file mode 100644 index 0000000..5f00553 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "easemob-sdk-test", + "version": "1.0.0", + "description": "环信 SDK 测试项目", + "main": "index.js", + "scripts": { + "test:msg": "node scripts/test-easemob-msg.js", + "test:polyfill": "node scripts/test-easemob-polyfill.js xhr2 \"1101260329209923#demo\" \"originclaw_bot\" \"OriginClaw2026!\"" + }, + "dependencies": { + "easemob-websdk": "^4.19.1", + "sql.js": "^1.13.0", + "ws": "^8.18.0", + "xhr2": "^0.2.1" + }, + "devDependencies": { + "happy-dom": "^20.8.9" + }, + "engines": { + "node": ">=18.0.0" + }, + "license": "MIT" +} diff --git a/scripts/easemob-polyfill.js b/scripts/easemob-polyfill.js new file mode 100644 index 0000000..a97b01c --- /dev/null +++ b/scripts/easemob-polyfill.js @@ -0,0 +1,137 @@ +/** + * 环信 SDK Node.js Polyfill 工具模块 + * + * 提供 easemob-websdk 在 Node.js 下运行所需的浏览器 API 模拟。 + * + * 用法: + * const { applyManualPolyfill, applyHappyDomPolyfill, printGlobalState } = require('./easemob-polyfill'); + * applyManualPolyfill(); + */ + +// ────────────────────────────────────────── +// 手动 Polyfill:逐项注入缺失的浏览器 API +// ────────────────────────────────────────── +function applyManualPolyfill() { + // window → globalThis(关键!让 typeof window === 'object',SDK 据此判断浏览器环境) + if (typeof globalThis.window === 'undefined') { + globalThis.window = globalThis; + console.log(' + window = globalThis'); + } + + // self → globalThis + if (typeof globalThis.self === 'undefined') { + globalThis.self = globalThis; + console.log(' + self = globalThis'); + } + + // WebSocket → ws + if (typeof globalThis.WebSocket === 'undefined') { + try { + const wsModule = require('ws'); + globalThis.WebSocket = wsModule.WebSocket || wsModule; + console.log(' + WebSocket = ws (Node.js)'); + } catch { + console.warn(' ! ws 包未安装,跳过 WebSocket polyfill'); + } + } + + // localStorage → 内存 Map + if (typeof globalThis.localStorage === 'undefined') { + const store = new Map(); + globalThis.localStorage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { store.set(key, String(value)); }, + removeItem: (key) => { store.delete(key); }, + clear: () => { store.clear(); }, + get length() { return store.size; }, + key: (_index) => null, + }; + console.log(' + localStorage = in-memory Map'); + } + + // XMLHttpRequest 由调用方按需注入(xhr2 / xmlhttprequest),此处不再自动注入 + + // navigator 基本模拟 + if (typeof globalThis.navigator === 'undefined') { + globalThis.navigator = { + userAgent: 'Node.js', + platform: process.platform, + appName: 'Node.js', + appVersion: process.version, + language: 'zh-CN', + }; + console.log(' + navigator = basic mock'); + } +} + +// ────────────────────────────────────────── +// happy-dom Polyfill:注入完整浏览器环境 +// ────────────────────────────────────────── +async function applyHappyDomPolyfill() { + let happyDOM; + try { + happyDOM = require('happy-dom'); + } catch { + console.error(' ✗ happy-dom 未安装'); + console.error(' 请运行: npm install happy-dom --save-dev'); + process.exit(1); + } + + // happy-dom v20+ uses Browser + BrowserContext API + if (happyDOM.Browser && happyDOM.BrowserContext) { + const browser = new happyDOM.Browser(); + const page = browser.newPage('https://a1.easemob.com'); + const window = page.mainFrame.window; + const document = window.document; + + globalThis.window = window; + globalThis.document = document; + globalThis.self = window; + globalThis.navigator = window.navigator; + globalThis.localStorage = window.localStorage; + globalThis.XMLHttpRequest = window.XMLHttpRequest; + globalThis.WebSocket = window.WebSocket; + globalThis.fetch = window.fetch.bind(window); + globalThis.Headers = window.Headers; + globalThis.Request = window.Request; + globalThis.Response = window.Response; + + console.log(' + happy-dom Browser + BrowserContext ✓'); + console.log(` + page URL: ${window.location.href}`); + return; + } + + // 旧版 GlobalRegistrator API (happy-dom < v15) + const Registrator = happyDOM.GlobalRegistrator; + if (Registrator && Registrator.register) { + await Registrator.register(); + console.log(' + happy-dom GlobalRegistrator.register() ✓'); + return; + } + + console.error(' ✗ happy-dom 版本不兼容,无法注入全局 API'); + console.error(' 当前版本:', require('happy-dom/package.json').version); + process.exit(1); +} + +// ────────────────────────────────────────── +// 打印当前全局 API 状态 +// ────────────────────────────────────────── +function printGlobalState() { + const apis = [ + 'self', 'WebSocket', 'localStorage', 'XMLHttpRequest', + 'navigator', 'fetch', 'window', 'document', + ]; + console.log('\n 全局 API 状态:'); + for (const api of apis) { + const exists = typeof globalThis[api] !== 'undefined'; + const type = typeof globalThis[api]; + console.log(` ${exists ? '✓' : '✗'} ${api}: ${exists ? type : 'undefined'}`); + } +} + +module.exports = { + applyManualPolyfill, + applyHappyDomPolyfill, + printGlobalState, +}; diff --git a/scripts/test-easemob-msg.js b/scripts/test-easemob-msg.js new file mode 100644 index 0000000..b9c6e12 --- /dev/null +++ b/scripts/test-easemob-msg.js @@ -0,0 +1,312 @@ +/** + * 环信 SDK 双用户消息互发测试脚本 + * + * 同时登录两个环信账号(originclaw_bot 和 testuser1), + * 测试双向消息收发: + * 1. 双方各自连接 + 登录 + * 2. testuser1 -> originclaw_bot 发送消息 + * 3. originclaw_bot 收到后自动回复 + * 4. originclaw_bot -> testuser1 主动发消息 + * 5. testuser1 收到后自动回复 + * + * 用法: + * node scripts/test-easemob-msg.js # 默认互发测试 + * node scripts/test-easemob-msg.js <自定义消息> # 指定初始消息内容 + */ + +const path = require('path'); +const fs = require('fs'); + +// ────────────────────────────────────────── +// 测试账号配置 +// ────────────────────────────────────────── +const ACCOUNTS = { + bot: { + label: 'originclaw_bot', + user: 'originclaw_bot', + pwd: 'OriginClaw2026!', + }, + tester: { + label: 'testuser1', + user: 'testuser1', + pwd: 'Test123456', + }, +}; +const APP_KEY = '1101260329209923#demo'; + +// ────────────────────────────────────────── +// Step 0: 从 SQLite 读取配置(获取服务器地址等) +// ────────────────────────────────────────── +async function loadConfigFromDB() { + const initSqlJs = require('sql.js'); + const dbPath = path.join(process.env.APPDATA, 'OriginClawAI', 'originclawai.sqlite'); + + if (!fs.existsSync(dbPath)) { + console.warn(`⚠ 数据库文件不存在: ${dbPath},使用默认配置`); + return null; + } + + const SQL = await initSqlJs(); + const buf = fs.readFileSync(dbPath); + const db = new SQL.Database(buf); + + const result = db.exec("SELECT value FROM im_config WHERE key = 'easemob'"); + db.close(); + + if (!result[0]?.values?.[0]?.[0]) { + console.warn('⚠ 数据库中没有环信配置,使用默认配置'); + return null; + } + + return JSON.parse(result[0].values[0][0]); +} + +// ────────────────────────────────────────── +// Polyfill +// ────────────────────────────────────────── +const { applyManualPolyfill } = require('./easemob-polyfill'); + +// ────────────────────────────────────────── +// 为账号创建连接实例并登录 +// ────────────────────────────────────────── +function createConnection(account, websdk, config) { + const conn = new websdk.connection({ + appKey: APP_KEY, + debug: false, + }); + + const peer = account.label === 'bot' ? ACCOUNTS.tester : ACCOUNTS.bot; + + // 消息统计(per-account) + const stats = { sent: 0, received: 0 }; + + conn.listen({ + onOpened: () => { + console.log(`\n [${account.label}] ✅ 登录成功!`); + }, + + onClosed: () => { + console.log(` [${account.label}] 连接已关闭`); + }, + + onError: (error) => { + const errMsg = error?.message || error?.data?.error_description || JSON.stringify(error); + console.error(` [${account.label}] ❌ Error: ${errMsg}`); + }, + + onTextMessage: (message) => { + stats.received++; + const from = message.from || 'unknown'; + const data = message.data || ''; + + console.log(`\n [${account.label}] 📩 收到消息 from=${from}: "${data}"`); + + // 自动 echo 回复 + const replyText = `[${account.label} Echo] 收到: "${data.substring(0, 30)}"`; + sendTextMessage(conn, websdk, from, replyText, account.label, stats); + }, + + onPresence: (message) => { + console.log(` [${account.label}] Presence: ${message?.type} from ${message?.from || ''}`); + }, + }); + + return { conn, stats, account, peer }; +} + +// ────────────────────────────────────────── +// 发送文本消息 +// ────────────────────────────────────────── +function sendTextMessage(conn, websdk, to, text, label, stats) { + try { + const message = new websdk.message({ + to, + type: 'chat', + msg: text, + chatType: 'singleChat', + }); + const result = conn.send(message); + // conn.send 可能返回 Promise + if (result && typeof result.catch === 'function') { + result.catch((err) => { + console.error(` [${label}] ❌ 发送失败(async): ${err?.message || err}`); + }); + } + stats.sent++; + console.log(` [${label}] 📤 已发送 -> ${to}: "${text}"`); + } catch (err) { + console.error(` [${label}] ❌ 发送失败: ${err.message}`); + } +} + +// ────────────────────────────────────────── +// 延时工具 +// ────────────────────────────────────────── +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ────────────────────────────────────────── +// 主流程 +// ────────────────────────────────────────── +async function main() { + const customMsg = process.argv.slice(2).join(' ') || '你好,这是双向测试消息!'; + + console.log('=== 环信双向消息收发测试 ==='); + console.log(` Bot 账号: ${ACCOUNTS.bot.user}`); + console.log(` 测试账号: ${ACCOUNTS.tester.user}`); + console.log(` 初始消息: "${customMsg}"`); + console.log('================================\n'); + + // Step 1: 读取数据库配置 + console.log('[Step 1] 读取配置...'); + let config; + try { + config = await loadConfigFromDB(); + if (config) { + console.log(` AppKey: ${config.appKey}`); + console.log(` REST Server: ${config.restServer || '(默认)'}`); + } else { + console.log(' 使用硬编码默认配置'); + } + } catch (err) { + console.warn(' ⚠ 读取配置失败,使用默认:', err.message); + } + + // Step 2: Polyfill + console.log('\n[Step 2] 应用 Polyfill...'); + applyManualPolyfill(); + + // WebSocket polyfill + try { + const wsModule = require('ws'); + const OriginalWebSocket = wsModule.WebSocket || wsModule; + globalThis.WebSocket = function DebugWebSocket(url, protocols, options) { + console.log(` [WS] new WebSocket(${JSON.stringify(url)})`); + if (protocols !== undefined) { + return new OriginalWebSocket(url, protocols, options); + } + return new OriginalWebSocket(url); + }; + Object.assign(globalThis.WebSocket, OriginalWebSocket); + globalThis.WebSocket.prototype = OriginalWebSocket.prototype; + console.log(' + WebSocket = ws (debug-wrapped)'); + } catch (err) { + console.warn(' ! ws 包未安装:', err.message); + } + + // XHR polyfill + try { + const { XMLHttpRequest } = require('xhr2'); + globalThis.XMLHttpRequest = XMLHttpRequest; + const baseUrl = config?.restServer || 'https://a1.easemob.com'; + const origOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) { + let finalUrl = url; + if (typeof url === 'string' && url.startsWith('/')) { + finalUrl = baseUrl + url; + } + return origOpen.call(this, method, finalUrl, ...rest); + }; + console.log(' + XMLHttpRequest = xhr2'); + } catch { + console.warn(' ! xhr2 包未安装'); + } + + // Step 3: 加载 SDK + console.log('\n[Step 3] 加载 easemob-websdk...'); + let websdk; + try { + const rawSdk = require('easemob-websdk'); + websdk = rawSdk.default || rawSdk; + console.log(' SDK 加载成功 ✓'); + } catch (err) { + console.error('✗ SDK 加载失败:', err.message); + process.exit(1); + } + + // Step 4: 创建两个连接 + console.log('\n[Step 4] 创建双用户连接...'); + + const botCtx = createConnection(ACCOUNTS.bot, websdk, config); + const testerCtx = createConnection(ACCOUNTS.tester, websdk, config); + + console.log(' 两个连接实例创建成功 ✓'); + + // Step 5: 双方登录 + console.log('\n[Step 5] 双方登录...'); + + const loginPromises = [ + (async () => { + try { + await botCtx.conn.open({ user: ACCOUNTS.bot.user, pwd: ACCOUNTS.bot.pwd }); + } catch (err) { + console.error(` [bot] 登录失败: ${err.message}`); + } + })(), + (async () => { + try { + await testerCtx.conn.open({ user: ACCOUNTS.tester.user, pwd: ACCOUNTS.tester.pwd }); + } catch (err) { + console.error(` [tester] 登录失败: ${err.message}`); + } + })(), + ]; + + await Promise.all(loginPromises); + + // 等待连接建立 + console.log('\n 等待双方连接建立...'); + await delay(5000); + + // Step 6: 双向发消息 + console.log('\n[Step 6] 开始双向消息测试...\n'); + + // tester -> bot + console.log('--- 测试 1: testuser1 -> originclaw_bot ---'); + sendTextMessage( + testerCtx.conn, websdk, + ACCOUNTS.bot.user, customMsg, + testerCtx.account.label, testerCtx.stats + ); + + await delay(3000); + + // bot -> tester + console.log('\n--- 测试 2: originclaw_bot -> testuser1 ---'); + sendTextMessage( + botCtx.conn, websdk, + ACCOUNTS.tester.user, `[Bot] ${customMsg}`, + botCtx.account.label, botCtx.stats + ); + + await delay(3000); + + // Step 7: 统计结果 + console.log('\n\n=== 测试结果 ==='); + console.log(` [${botCtx.account.label}] 发送: ${botCtx.stats.sent}, 接收: ${botCtx.stats.received}`); + console.log(` [${testerCtx.account.label}] 发送: ${testerCtx.stats.sent}, 接收: ${testerCtx.stats.received}`); + console.log('\n保持连接中... (Ctrl+C 退出)\n'); + + // 优雅退出 + const cleanup = () => { + console.log('\n=== 退出统计 ==='); + console.log(` [${botCtx.account.label}] 发送: ${botCtx.stats.sent}, 接收: ${botCtx.stats.received}`); + console.log(` [${testerCtx.account.label}] 发送: ${testerCtx.stats.sent}, 接收: ${testerCtx.stats.received}`); + try { botCtx.conn.close(); } catch {} + try { testerCtx.conn.close(); } catch {} + setTimeout(() => process.exit(0), 500); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); +} + +// 防止未处理的 Promise 拒绝导致进程崩溃 +process.on('unhandledRejection', (reason) => { + console.error('⚠ unhandledRejection:', reason?.message || reason); +}); + +// ─── 启动 ─── +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/scripts/test-easemob-polyfill.js b/scripts/test-easemob-polyfill.js new file mode 100644 index 0000000..711f485 --- /dev/null +++ b/scripts/test-easemob-polyfill.js @@ -0,0 +1,219 @@ +/** + * 环信 SDK Node.js Polyfill 测试脚本 + * + * 用法: + * node scripts/test-easemob-polyfill.js [方案] [appKey] [user] [pwd] [restServer] [socketServer] + * + * 方案: + * manual - 手动 polyfill (self + ws + localStorage) + * happydom - 使用 happy-dom 注入完整浏览器环境 + * xhr2 - 手动 polyfill + xhr2 包 (推荐) + * + * 私有云配置: + * restServer - REST API 服务器地址 (如 http://a1.easemob.com) + * socketServer - WebSocket 服务器地址 (如 ws://im-api-v2.easemob.com/ws) + * + * 示例: + * node scripts/test-easemob-polyfill.js xhr2 "your#appkey" "username" "password" + * node scripts/test-easemob-polyfill.js xhr2 "your#appkey" "username" "password" "http://a1.easemob.com" "ws://im-api-v2.easemob.com/ws" + */ + +const { applyManualPolyfill, applyHappyDomPolyfill, printGlobalState } = require('./easemob-polyfill'); + +const [,, method = 'xhr2', appKey, user, pwd, restServer, socketServer] = process.argv; + +if (!appKey || !user || !pwd) { + console.log('用法: node scripts/test-easemob-polyfill.js [restServer] [socketServer]'); + console.log(''); + console.log('方案说明:'); + console.log(' manual - 手动 polyfill (无 XMLHttpRequest)'); + console.log(' xhr2 - 手动 polyfill + xhr2 包 (推荐)'); + console.log(' happydom - 使用 happy-dom 注入完整浏览器环境'); + console.log(''); + console.log('私有云参数:'); + console.log(' restServer - REST API 地址 (如 http://a1.easemob.com)'); + console.log(' socketServer - WebSocket 地址 (如 ws://im-api-v2.easemob.com/ws)'); + console.log(''); + console.log('示例:'); + console.log(' # 公有云:'); + console.log(' node scripts/test-easemob-polyfill.js xhr2 "org#app" "user1" "pass1"'); + console.log(' # 私有云:'); + console.log(' node scripts/test-easemob-polyfill.js xhr2 "org#app" "user1" "pass1" "http://a1.easemob.com" "ws://im-api-v2.easemob.com/ws"'); + process.exit(1); +} + +const resolvedRest = restServer || 'https://a1.easemob.com'; +const resolvedSocket = socketServer || 'wss://im-api-v2.easemob.com/ws'; + +console.log('=== 环信 SDK Node.js Polyfill 测试 ==='); +console.log(`方案: ${method}`); +console.log(`AppKey: ${appKey}`); +console.log(`User: ${user}`); +console.log(`REST Server: ${resolvedRest}`); +console.log(`Socket Server: ${resolvedSocket}`); +console.log(''); + +async function main() { + // ─── Step 1: 应用 polyfill ─── + console.log('[Step 1] 应用 polyfill...'); + + if (method === 'happydom') { + await applyHappyDomPolyfill(); + } else { + applyManualPolyfill(); + // 按 method 选择 XHR polyfill + if (method === 'xhr2') { + try { + const { XMLHttpRequest } = require('xhr2'); + globalThis.XMLHttpRequest = XMLHttpRequest; + console.log(' + XMLHttpRequest = xhr2'); + } catch { + console.warn(' ! xhr2 包未安装,跳过'); + } + } else if (method === 'xhr1') { + try { + const { XMLHttpRequest } = require('xmlhttprequest'); + globalThis.XMLHttpRequest = XMLHttpRequest; + console.log(' + XMLHttpRequest = xmlhttprequest (旧版)'); + } catch { + console.warn(' ! xmlhttprequest 包未安装,跳过'); + } + } + // manual 方案不注入 XMLHttpRequest + + // Patch XHR.open: SDK 用相对路径,Node.js XHR 需要绝对 URL + if (globalThis.XMLHttpRequest) { + const BASE_URL = resolvedRest; + const origOpen = globalThis.XMLHttpRequest.prototype.open; + globalThis.XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) { + let finalUrl = url; + if (typeof url === 'string' && url.startsWith('/')) { + finalUrl = BASE_URL + url; + console.log(` >>> XHR patch: ${method} ${url} -> ${finalUrl}`); + } + return origOpen.call(this, method, finalUrl, ...rest); + }; + console.log(' + XHR.open patched (相对路径 -> ' + BASE_URL + ')'); + } + } + + console.log('[Step 1] Polyfill 完成 ✓'); + printGlobalState(); + + // ─── Step 2: 加载 SDK ─── + console.log('\n[Step 2] 加载 easemob-websdk...'); + let websdk; + try { + const rawSdk = require('easemob-websdk'); + websdk = rawSdk.default || rawSdk; + console.log('[Step 2] SDK 加载成功 ✓'); + console.log(` - SDK keys: ${Object.keys(websdk).slice(0, 10).join(', ')}`); + console.log(` - Has connection: ${typeof websdk.connection}`); + console.log(` - Has message: ${typeof websdk.message}`); + } catch (err) { + console.error('[Step 2] SDK 加载失败 ✗'); + console.error(` Error: ${err.message}`); + console.error(` Stack: ${err.stack}`); + process.exit(1); + } + + // ─── Step 3: 创建连接实例 ─── + console.log('\n[Step 3] 创建连接实例...'); + let conn; + try { + conn = new websdk.connection({ + appKey, + isHttpDNS: false, + restUrl: resolvedRest, + msyncUrl: resolvedSocket, + }); + console.log('[Step 3] 连接实例创建成功 ✓'); + console.log(` - conn type: ${typeof conn}`); + console.log(` - Has open: ${typeof conn.open}`); + console.log(` - Has listen: ${typeof conn.listen}`); + console.log(` - Has close: ${typeof conn.close}`); + } catch (err) { + console.error('[Step 3] 创建连接实例失败 ✗'); + console.error(` Error: ${err.message}`); + console.error(` Stack: ${err.stack}`); + process.exit(1); + } + + // ─── Step 4: 注册事件监听 ─── + console.log('\n[Step 4] 注册事件监听...'); + try { + conn.listen({ + onOpened: () => { + console.log('\n★ [Event] 连接已打开 (onOpened) ★'); + printGlobalState(); + console.log(''); + console.log('=== 测试成功!环信 SDK 在 Node.js 下正常工作 ==='); + console.log('5 秒后自动断开...'); + setTimeout(() => { + try { conn.close(); } catch (e) { /* ignore */ } + setTimeout(() => process.exit(0), 1000); + }, 5000); + }, + onClosed: () => { + console.log('[Event] 连接已关闭 (onClosed)'); + }, + onError: (error) => { + const errMsg = error?.message || error?.data?.error_description || JSON.stringify(error); + console.error(`\n✗ [Event] 错误 (onError): ${errMsg}`); + // 不退出,有些错误之后还能重连 + }, + onTextMessage: (message) => { + console.log(`\n[Event] 收到文本消息: ${JSON.stringify(message).substring(0, 200)}`); + }, + onPictureMessage: (message) => { + console.log(`\n[Event] 收到图片消息: from=${message.from}`); + }, + onAudioMessage: (message) => { + console.log(`\n[Event] 收到语音消息: from=${message.from}`); + }, + onVideoMessage: (message) => { + console.log(`\n[Event] 收到视频消息: from=${message.from}`); + }, + onFileMessage: (message) => { + console.log(`\n[Event] 收到文件消息: from=${message.from}`); + }, + onPresence: (message) => { + console.log(`[Event] Presence: ${message?.type}`); + }, + }); + console.log('[Step 4] 事件监听注册成功 ✓'); + } catch (err) { + console.error('[Step 4] 注册事件监听失败 ✗'); + console.error(` Error: ${err.message}`); + process.exit(1); + } + + // ─── Step 5: 登录 ─── + console.log('\n[Step 5] 开始登录...'); + console.log(` user: ${user}`); + try { + await conn.open({ user, pwd }); + console.log('[Step 5] open() 已调用,等待 onOpened 回调...'); + } catch (err) { + console.error('[Step 5] 登录失败 ✗'); + console.error(` Error: ${err.message}`); + console.error(` Stack: ${err.stack}`); + process.exit(1); + } + + // 超时保护:30 秒无 onOpened 则退出 + setTimeout(() => { + console.error('\n✗ 超时:30 秒内未收到 onOpened 回调'); + console.error(' 可能原因:'); + console.error(' 1. WebSocket 连接失败(检查网络/防火墙)'); + console.error(' 2. Polyfill 不完整(缺少必要的浏览器 API)'); + console.error(' 3. 账号密码错误'); + process.exit(1); + }, 30000); +} + +// ─── 启动 ─── +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +});