feat: 初始化环信 SDK 测试项目

- 添加项目配置文档 (AGENTS.md)
- 创建 package.json,包含环信 SDK 及测试依赖
- 添加环信 polyfill 工具模块
- 添加双用户消息互发测试脚本
- 添加 polyfill 测试脚本

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
zyh
2026-03-29 23:14:09 +00:00
commit fd549c6e3d
5 changed files with 897 additions and 0 deletions

206
AGENTS.md Normal file
View File

@@ -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 `<html>` 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.

23
package.json Normal file
View File

@@ -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"
}

137
scripts/easemob-polyfill.js Normal file
View File

@@ -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,
};

312
scripts/test-easemob-msg.js Normal file
View File

@@ -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);
});

View File

@@ -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 <manual|xhr2|happydom> <appKey> <user> <pwd> [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);
});