Files
xiaoshuai 2a641b1eff feat(serial): USB 页面下线, 改成串口映射 + 串口助手
## USB 整体下线

把 USB 功能真删掉, 不是只藏 UI:

- 删 `src/main/remote/usb.ts` + `src/renderer/src/components/remote/UsbPanel.vue`
- 删 `usb` (node-usb) 和 `node-hid` 两个依赖 —— 查过, 本来就没被真正用起来
- `asarUnpack` 去掉 node-usb/usb/node-hid 的规则
- ChatView 的 tab 由 "USB" 改成 "串口"

## 串口映射 + 串口助手 (新)

`src/main/remote/serial.ts` (取代 usb.ts) 只做串口, 一个文件两件事:

- **串口映射**: 把一端的真实串口映射成另一端可用的虚拟串口
  (Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com 建 COM 对)。
  方向做成 "我用对方的" / "对方用我的" 两种, 两边都能发起。
- **串口助手**: 不建虚拟口时, 应用自己当助手 —— 字节流直接显示,
  能发文本/HEX, 能追加 LF, 能循环发送, 能清屏/存日志。

`src/renderer/src/components/remote/SerialPanel.vue` (新) 把两者做在一个面板里:
设备表 (串口 / VID:PID / 产品 / 制造商)、串口参数 (波特率/数据位/停止位/校验)、
TX/RX 时间戳 + hex + ascii 视图、虚拟口路径横幅 (可一键复制)。

## 串口助手要能调 Linux

Linux 上调串口最常见的两个坑, 都做成产品功能:

- **没权限**: 诊断 dialout 组 + socat + 串口列表, 一键提权修复
  (`sudo usermod -aG dialout $USER`, 提示要重新登录才生效)。
  "设备没插" 和 "没权限" 分开报错, 不再混成一句 "打开失败"。
- **缺 socat**: 建虚拟口前先探 socat, 没有就弹安装指引 (apt/dnf),
  免得 attach 做到一半才失败。

平台化文案也分开了: Linux 提示 dialout, Windows 提示设备管理器, macOS 提示隐私授权。

## 线上格式一个字节没动 (兼容性关键)

局域网里常有一台机器跑旧版本, 所以:

- `PROTOCOL_VERSION` 仍然 = 1
- WS 帧类型仍然叫 `'usb'`
- 只把内部类型改名 (`SerialDeviceInfo` / `SerialAttachConfig` / ...)

代价: 混版本时已下线的 USB 字节桥不可用。换来: 新旧版本聊天照常,
串口映射也照常, 不会因为升协议被踢下线。**别手贱顺手改对** (AGENTS.md 坑 #10)。

## 其它

- 主进程 `output` 事件带方向 (rx/tx), 助手才能分清"我发的"和"设备回的";
  客户端自己发出的字节不回显成 rx (无回环, S5 用例守住)
- 虚拟串口 (vcom) 模式下 app 不读内部端, 避免和 socat 抢数据
- `src/main/permissions.ts` 重写成串口专用, 删掉 USB/HID 相关诊断与修复
- 保留 `src/main/remote/vcom.ts` (socat/com0com 配对) 不动, 与 serial.ts 分离

## 验证

- npm run typecheck        ✅
- npm run selftest         ✅ 79 通过 / 0 失败 (USB 用例整体换成 S1-S9 串口用例)
- npm run build            ✅
- npm run package:linux    ✅ 新 deb (84MB)
- npm run selftest:package ✅ 4/4 (改成校验 serialport 原生绑定已解包)

真机 (CDP 驱动真实窗口) 验证过: 串口 tab 能打开, 设备表列到 32 个 /dev/ttyS*,
助手区 + hex 区在, 波特率/数据位/停止位/校验/循环发送/清屏/保存日志 按钮齐全,
方向切换会换设备列表 (我用对方的 = 对方设备, 对方用我的 = 本机设备)。
2026-10-21 18:04:55 +08:00

1770 lines
90 KiB
TypeScript

// LocalNetMsg 远程功能自测 (terminal / forward / serial)
// 在一个进程里跑两个虚拟节点 (A = 控制端, B = 被控端), 走真实的
// ChatServer + ChatClient + 三个 remote manager, 接线方式与 src/main/ipc.ts 完全一致.
//
// 用法:
// npm run selftest # 全部
// npm run selftest -- terminal # 只跑某些用例
import { spawn, ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, rmSync, openSync, closeSync, readSync, writeSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
import * as fs from 'node:fs'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { connect } from 'node:net'
import { createServer, Server, request as httpRequest } from 'node:http'
import { createHash } from 'node:crypto'
import { ChatServer } from '../../src/main/chat-server'
import { ChatClient } from '../../src/main/chat-client'
import { FileServer } from '../../src/main/file-server'
import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive'
import { computeWsState } from '../../src/main/ws-state'
import { safeFileName } from '../../src/main/safe-name'
import { scanLocalDir, scanSync, applySyncItems, type SyncFileInfo, type SyncContext } from '../../src/main/sync'
import {
ManifestStore, planSync, mergePeer, markLocalDeleted, reconcileLocal,
type Manifest, type ManifestWireEntry,
} from '../../src/main/sync-manifest'
import { RelationStore, type Grant, type Relation } from '../../src/main/relations'
import { RelationService } from '../../src/main/relation-service'
import { listPendingApprovals, replyApproval } from '../../src/main/remote/approval'
import { defaultCwd, defaultShell } from '../../src/main/remote/shell'
import { TerminalManager } from '../../src/main/remote/terminal'
import { ForwardManager } from '../../src/main/remote/forward'
import { SerialManager } from '../../src/main/remote/serial'
import { updateSettings } from '../../src/main/settings'
import { listAudit } from '../../src/main/db'
import {
isImageMime, pickThumbWidth, chooseThumbEncoding, pickImageSource,
THUMB_MAX_WIDTH,
} from '../../src/main/image-core'
import { makeThumbFromFile, copyToClipboard, __clearThumbCache } from '../../src/main/image'
import { incrUnread, clearUnread, getUnread, totalUnread } from '../../src/main/db'
import { unreadActionForIncoming } from '../../src/renderer/src/utils/unread'
import electronStub from './electron-stub.cjs'
import type {
DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, SerialFrame,
RelationRequestFrame, RelationAcceptFrame, RelationRejectFrame,
} from '../../src/main/protocol'
import { PROTOCOL_VERSION } from '../../src/main/protocol'
// ============ 小型测试框架 ============
interface CaseResult { name: string; ok: boolean; detail: string; ms: number }
const results: CaseResult[] = []
let current = ''
function assert(cond: any, msg: string) {
if (!cond) throw new Error(msg)
}
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) }
async function runCase(name: string, fn: () => Promise<void>) {
current = name
const t0 = Date.now()
try {
await fn()
results.push({ name, ok: true, detail: '', ms: Date.now() - t0 })
console.log(` \x1b[32mPASS\x1b[0m ${name} (${Date.now() - t0}ms)`)
} catch (e: any) {
const detail = String(e?.message || e)
results.push({ name, ok: false, detail, ms: Date.now() - t0 })
console.log(` \x1b[31mFAIL\x1b[0m ${name} (${Date.now() - t0}ms): ${detail}`)
}
}
// ============ 虚拟节点 ============
interface UiEvent { kind: string; sessionId?: string; peerId?: string; data?: string; text?: string; reason?: string }
class Node {
info: DeviceInfo
server: ChatServer
client: ChatClient
fileServer: FileServer
terminal: TerminalManager
forward: ForwardManager
serial: SerialManager
events: UiEvent[] = []
driveDir = ''
driveToken = ''
// ---- 关系层 (同步盘) ----
/** 关系库 (JSON 文件) */
relations!: RelationStore
/** 文件关系表仓库 (墓碑 + 序号) */
manifestStore!: ManifestStore
/** 关系服务 (建关系/授权/应用对方的关系表) */
relationService!: RelationService
/** 关系库文件路径 (自测里要重新打开验证持久化) */
relationsFile = ''
/** 文件关系表文件路径 */
manifestFile = ''
/** 本节点的同步目录 (自己选的任意本地目录, 不必在网盘里) */
syncLocalDir = ''
/** 实际同步根 = 顶层目录 + 关系子目录 (关系建立后才知道) */
syncRoot = ''
constructor(id: string, name: string) {
this.info = {
deviceId: id, name, hostname: name, platform: 'linux', version: PROTOCOL_VERSION,
appVersion: '0.1.0', address: '127.0.0.1', chatPort: 0, filePort: 0, ts: Date.now(),
}
this.server = new ChatServer(0)
this.client = new ChatClient(this.info)
this.fileServer = new FileServer(0, join(tmpdir(), `lnm-selftest-dl-${id.slice(-4)}`), {
getDriveRoot: () => this.driveDir,
getDriveToken: () => this.driveToken,
isDriveEnabled: () => true,
// 新版: 按关系里的 per-peer 令牌鉴权 (网盘 / 同步区各要各的授权)
authorize: (token, peerId) => this.relationService
? this.relationService.authorizeDrive(token, peerId)
: { ok: false, reason: '未就绪' },
getSyncScope: (relationId) => this.relationService?.getSyncScope(relationId) ?? null,
getSyncManifest: ({ relationId }) => this.relationService?.getSyncManifest(relationId) ?? null,
onSyncManifest: ({ relationId, wire }) => this.relationService
? this.relationService.applyPeerManifest(relationId, wire)
: { applied: [], conflicts: [] },
})
this.terminal = new TerminalManager(this.client, id)
this.forward = new ForwardManager(this.client)
this.serial = new SerialManager(this.client)
}
async start() {
const port = await this.server.start()
this.info.chatPort = port
const filePort = await this.fileServer.start()
this.info.filePort = filePort
// 网盘目录: 放几个测试文件
this.driveDir = join(tmpdir(), `lnm-selftest-drive-${this.info.deviceId.slice(-4)}`)
rmSync(this.driveDir, { recursive: true, force: true })
mkdirSync(join(this.driveDir, 'sub', 'deep'), { recursive: true })
writeFileSync(join(this.driveDir, 'hello.txt'), 'HELLO-FROM-DRIVE')
writeFileSync(join(this.driveDir, 'sub', 'nested.txt'), 'NESTED-FROM-DRIVE')
writeFileSync(join(this.driveDir, 'sub', 'deep', 'deeper.txt'), 'DEEPER-FROM-DRIVE')
this.driveToken = `tok-${this.info.deviceId.slice(-4)}`
this.info.driveToken = this.driveToken
// ---- 关系层: 关系库 + 文件关系表 + 关系服务 ----
const tmpBase = join(tmpdir(), `lnm-selftest-rel-${this.info.deviceId.slice(-4)}-${Date.now()}`)
rmSync(tmpBase, { recursive: true, force: true })
mkdirSync(tmpBase, { recursive: true })
this.relationsFile = join(tmpBase, 'relations.json')
this.manifestFile = join(tmpBase, 'manifest.json')
this.relations = new RelationStore(this.relationsFile)
this.manifestStore = new ManifestStore(this.manifestFile)
this.relationService = new RelationService({
store: this.relations,
manifestStore: this.manifestStore,
self: this.info,
send: (peerId, frame) => this.client.send(frame, peerId),
notify: (event, payload) => this.events.push({ kind: event, peerId: payload?.peerId }),
})
// 本节点的同步目录: 自己选的任意本地目录 (独立于网盘)
this.syncLocalDir = join(tmpBase, 'sync')
rmSync(this.syncLocalDir, { recursive: true, force: true })
mkdirSync(this.syncLocalDir, { recursive: true })
this.syncRoot = this.syncLocalDir
// ---- 与 src/main/ipc.ts 完全一致的帧路由 ----
const TRACE = !!process.env.LNM_TRACE
const route = (peer: DeviceInfo, frame: WsFrame) => {
if (TRACE) {
const p: any = (frame as any).payload
console.log(`[trace ${this.info.name}] <- ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`)
}
if (frame.type === 'terminal') {
this.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
.catch((e) => console.warn(`[${this.info.name}] terminal handle error:`, e?.message))
} else if (frame.type === 'forward') {
this.forward.handleIncoming(peer, frame.payload as ForwardFrame)
} else if (frame.type === 'usb') { // 线上帧类型没改 (兼容老版本)
this.serial.handleIncoming(peer, frame.payload as SerialFrame)
} else if (frame.type === 'relation:request') {
this.relationService.handleRelationRequest(peer, frame.payload as RelationRequestFrame)
.catch((e) => console.warn(`[${this.info.name}] relation request error:`, e?.message))
} else if (frame.type === 'relation:accept') {
this.relationService.handleRelationAccept(peer, frame.payload as RelationAcceptFrame)
} else if (frame.type === 'relation:reject') {
this.relationService.handleRelationReject(peer, frame.payload as RelationRejectFrame)
}
}
this.server.on('frame', route)
this.client.on('message', route)
// ---- 渲染层能看到的事件 (对应 ipc.ts 里 broadcastToRenderer) ----
this.terminal.on('output', (sessionId, bytes, peerId) =>
this.events.push({ kind: 'terminal:output', sessionId, peerId, data: bytes.toString('base64') }))
this.terminal.on('sessionOpened', (sessionId, peerId) =>
this.events.push({ kind: 'terminal:opened', sessionId, peerId }))
this.terminal.on('sessionClosed', (sessionId, peerId, reason) =>
this.events.push({ kind: 'terminal:closed', sessionId, peerId, reason }))
this.terminal.on('approvalRequested', (req) =>
this.events.push({ kind: 'approval', peerId: req.peerId, text: req.detail }))
this.forward.on('sessionOpened', (i: any) =>
this.events.push({ kind: 'forward:opened', sessionId: i.sessionId, peerId: i.peerId }))
this.forward.on('sessionClosed', (i: any) =>
this.events.push({ kind: 'forward:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason }))
this.serial.on('output', (sessionId, bytes, peerId, dir) =>
this.events.push({ kind: 'serial:output', sessionId, peerId, dir, data: bytes.toString('base64') }))
this.serial.on('sessionOpened', (i: any) =>
this.events.push({ kind: 'serial:opened', sessionId: i.sessionId, peerId: i.peerId }))
this.serial.on('sessionClosed', (i: any) =>
this.events.push({ kind: 'serial:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason }))
this.serial.on('sessionError', (i: any) =>
this.events.push({ kind: 'serial:error', sessionId: i.sessionId, peerId: i.peerId, text: i.error }))
this.client.on('error', (_peer, err) => console.warn(`[${this.info.name}] ws error: ${err.message}`))
// 追踪本端发出的帧
if (TRACE) {
const origSend = this.client.send.bind(this.client)
;(this.client as any).send = (frame: WsFrame, deviceId?: string) => {
const p: any = (frame as any).payload
console.log(`[trace ${this.info.name}] -> ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`)
return origSend(frame, deviceId)
}
}
}
connectTo(other: Node) {
this.client.connectTo({ ...other.info })
}
// 该节点收到的某个 kind 事件 (最近 n 秒内)
hasEvent(kind: string, pred?: (e: UiEvent) => boolean) {
return this.events.some(e => e.kind === kind && (!pred || pred(e)))
}
findEvents(kind: string) { return this.events.filter(e => e.kind === kind) }
clearEvents() { this.events = [] }
async stop() {
try { await this.server.stop() } catch {}
try { this.client.stop() } catch {}
try { this.terminal.stop() } catch {}
try { this.forward.stop() } catch {}
try { this.serial.stop() } catch {}
}
}
function decode(b64: string) { return Buffer.from(b64, 'base64').toString('utf8') }
// 读文本文件, 读不到就当空串 (断言里好写)
function readText(p: string): string {
try { return readFileSync(p, 'utf8') } catch { return '' }
}
// ============ 小工具 ============
function startHttpServer(port: number, body: string): Promise<Server> {
return new Promise((resolve, reject) => {
const s = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end(body)
})
s.on('error', reject)
s.listen(port, '127.0.0.1', () => resolve(s))
})
}
// 发起 HTTP 请求, 收到完整响应后**保持连接** (隧道是单连接设计, 连接断了整个转发就结束)
function httpGet(port: number, path = '/', timeoutMs = 5000): Promise<{ body: string; sock: Socket }> {
return new Promise((resolve, reject) => {
const req = connect({ host: '127.0.0.1', port }, () => {
req.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: keep-alive\r\n\r\n`)
})
let buf = ''
let settled = false
const finish = () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve({ body: buf, sock: req })
}
// 目标服务是 keep-alive, 连接不会自己关 —— 按 Content-Length 判完整, 别等 'end'
const tryComplete = () => {
const idx = buf.indexOf('\r\n\r\n')
if (idx < 0) return
const m = /content-length:\s*(\d+)/i.exec(buf.slice(0, idx))
const need = m ? Number(m[1]) : 0
if (buf.length >= idx + 4 + need) finish()
}
const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error(`http 超时 (收到 ${buf.length}B): ${buf.slice(0, 120)}`)) } }, timeoutMs)
req.on('data', (d) => { buf += d.toString('utf8'); tryComplete() })
req.on('end', () => finish())
req.on('error', (e) => { if (!settled) { settled = true; clearTimeout(timer); reject(e) } })
})
}
function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 50): Promise<void> {
return new Promise((resolve, reject) => {
const t0 = Date.now()
const tick = () => {
if (pred()) return resolve()
if (Date.now() - t0 > timeoutMs) return reject(new Error('等待超时'))
setTimeout(tick, stepMs)
}
tick()
})
}
// 等待某个条件成立并**返回找到的东西** (关系授权弹窗之类的异步产物)
async function waitForItem<T>(fn: () => T | undefined | null | false, timeoutMs = 5000, stepMs = 30): Promise<T> {
const t0 = Date.now()
for (;;) {
const v = fn()
if (v) return v
if (Date.now() - t0 > timeoutMs) throw new Error('等待超时')
await sleep(stepMs)
}
}
// 两个节点双向连上 (模拟 discovery 找到对方后双方都连)
async function link(a: Node, b: Node) {
a.connectTo(b)
b.connectTo(a)
await waitFor(() => a.client.isOpen(b.info.deviceId) && b.client.isOpen(a.info.deviceId), 8000)
}
/**
* 建立一条关系: 发起方发请求 -> 对方弹窗 -> 同意 -> 双方关系落库且 active。
* 授权队列 (approval.ts) 是模块级单例, 用 peerId 区分是哪条请求。
*/
async function establishRelation(
from: Node, to: Node,
opts?: { grants?: Grant[]; peerGrants?: Grant[]; fromPath?: string; toPath?: string },
): Promise<{ relationId: string; fromRel: Relation; toRel: Relation }> {
const grants = opts?.grants ?? ['drive', 'sync']
const peerGrants = opts?.peerGrants ?? grants
const r = from.relationService.requestRelation({
peerId: to.info.deviceId,
peerName: to.info.name,
peerAddress: to.info.address,
peerChatPort: to.info.chatPort,
peerFilePort: to.info.filePort,
syncLocalDir: from.syncLocalDir,
syncRemotePath: opts?.fromPath ?? `同步/${from.info.name}`,
grants,
})
assert(r.ok, `发起关系失败: ${r.reason}`)
assert(!!r.relationId, '发起关系没有返回 relationId')
const relationId = r.relationId!
// 对方那边应该弹出授权窗口
const req = await waitForItem(
() => listPendingApprovals().find(x => x.kind === 'relation' && x.peerId === from.info.deviceId),
5000,
).catch(() => { throw new Error('对方没有弹出关系授权窗口') })
const replied = replyApproval(req.requestId, true, {
grants: peerGrants,
syncLocalDir: to.syncLocalDir,
syncRemotePath: opts?.toPath ?? `同步/${to.info.name}`,
})
assert(replied, '回复关系授权失败 (请求已超时?)')
// 双方关系都要变成 active
await waitFor(() => from.relations.getRelation(relationId)?.state === 'active', 5000)
.catch(() => { throw new Error('发起方的关系没有变成 active') })
await waitFor(() => to.relations.getRelation(relationId)?.state === 'active', 5000)
.catch(() => { throw new Error('对方的关系没有落库') })
const fromRel = from.relations.getRelation(relationId)!
const toRel = to.relations.getRelation(relationId)!
// 实际同步根 = 顶层目录 + 按关系隔离的子目录 (测试要按它来放文件)
from.syncRoot = join(from.syncLocalDir, fromRel.syncSubdir || '')
to.syncRoot = join(to.syncLocalDir, toRel.syncSubdir || '')
mkdirSync(from.syncRoot, { recursive: true })
mkdirSync(to.syncRoot, { recursive: true })
return { relationId, fromRel, toRel }
}
/**
* 同步上下文: 本地目录 + 对方同步区 + 关系里的对方令牌 + 关系表仓库。
* token 用关系里的 peerToken (对方签发给我的那个), peerId 传我的 deviceId。
*/
function syncCtx(node: Node, peer: Node, relationId: string): SyncContext {
const rel = node.relations.getRelation(relationId)
assert(!!rel, `关系不存在: ${relationId}`)
const token = rel!.peerToken
assert(!!token, '关系里没有对方的令牌')
return {
peer: { ...peer.info, driveToken: token },
localDir: node.syncRoot,
remotePath: '',
token,
relationId,
store: node.manifestStore,
peerSyncRoot: rel!.peerSyncRoot,
// 请求里要带**我方**的 deviceId (服务端靠它确认令牌确实是签给对方的)
selfDeviceId: node.info.deviceId,
}
}
// 发一个 HTTP 请求, 拿状态码 + 原始文本 (用来精确断言 403 之类的)
function httpJson(port: number, path: string, opts?: {
method?: string; headers?: Record<string, string>; body?: Buffer | string; timeoutMs?: number
}): Promise<{ status: number; text: string; data: any }> {
return new Promise((resolve) => {
let done = false
const finish = (status: number, text: string) => {
if (done) return
done = true
let data: any = null
try { data = JSON.parse(text) } catch { /* 非 JSON 也行 */ }
resolve({ status, text, data })
}
const req = httpRequest({
host: '127.0.0.1', port, method: opts?.method || 'GET', path,
headers: opts?.headers || {}, timeout: opts?.timeoutMs ?? 15000,
}, (res) => {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => finish(res.statusCode || 0, Buffer.concat(chunks).toString('utf8')))
})
req.on('timeout', () => { req.destroy(); finish(0, '') })
req.on('error', () => finish(0, ''))
if (opts?.body) req.end(opts.body)
else req.end()
})
}
// 造一个 socat 串口对: 一头是 PTY (给被控端当真实串口), 另一头我们直接读写
async function startSocatSerial(linkDir: string): Promise<{ proc: ChildProcess; userPath: string; internalPath: string }> {
const userPath = join(linkDir, 'user')
const internalPath = join(linkDir, 'internal')
const proc = spawn('socat', [
'-d', '-d',
`pty,raw,echo=0,link=${userPath}`,
`pty,raw,echo=0,link=${internalPath}`,
], { stdio: ['ignore', 'pipe', 'pipe'] })
let err = ''
proc.stderr?.on('data', (b) => { err += b.toString() })
const t0 = Date.now()
while (!(existsSync(userPath) && existsSync(internalPath))) {
if (Date.now() - t0 > 5000) { proc.kill(); throw new Error('socat 起不来: ' + err) }
await sleep(100)
}
return { proc, userPath, internalPath }
}
// 打开 PTY 的一端做非阻塞读写
function openPty(path: string) {
const fd = openSync(path, fs.constants.O_RDWR | fs.constants.O_NONBLOCK)
return {
fd,
write: (s: string | Buffer) => writeSync(fd, s),
read: (): string => {
const buf = Buffer.alloc(4096)
try {
const n = readSync(fd, buf, 0, buf.length, null)
return n > 0 ? buf.slice(0, n).toString('utf8') : ''
} catch (e: any) {
if (e?.code === 'EAGAIN' || e?.code === 'EWOULDBLOCK') return ''
throw e
}
},
close: () => { try { closeSync(fd) } catch {} },
}
}
// ============ 主流程 ============
async function main() {
const filter = process.argv.slice(2).filter(a => !a.startsWith('-'))
const want = (name: string) => !filter.length || filter.some(f => name.toLowerCase().includes(f.toLowerCase()))
const step = (m: string) => console.log(`[step] ${m}`)
step('建节点 A')
const A = new Node('peer-aaaaaaaa-0000-4000-8000-00000000000a', '控制端A')
step('建节点 B')
const B = new Node('peer-bbbbbbbb-0000-4000-8000-00000000000b', '被控端B')
step('建节点 E (只给 sync 授权的对端, 测授权分级/多关系隔离)')
const E = new Node('peer-eeeeeeee-0000-4000-8000-00000000000e', '同步授权E')
step('建节点 F (测拒绝)')
const F = new Node('peer-ffffffff-0000-4000-8000-00000000000f', '关系测试F')
step('建节点 G (测吊销)')
const G = new Node('peer-99999999-0000-4000-8000-000000000009', '关系测试G')
step('启动 A')
await A.start()
step('启动 B')
await B.start()
step('启动 E')
await E.start()
step('启动 F')
await F.start()
step('启动 G')
await G.start()
step(`节点就绪 A(:${A.info.chatPort}) B(:${B.info.chatPort}) E(:${E.info.chatPort}) F(:${F.info.chatPort}) G(:${G.info.chatPort})`)
// 双向连通 (模拟 discovery 找到对方后双方都连上)
step('建立 WS 连接')
A.connectTo(B)
B.connectTo(A)
await waitFor(() => A.client.isOpen(B.info.deviceId) && B.client.isOpen(A.info.deviceId), 8000)
console.log(`\n两个节点已连通: A(:${A.info.chatPort}) <-> B(:${B.info.chatPort})\n`)
// 授权: 双方互相放行, 避免弹窗 (等价于设置里勾了"允许")
updateSettings({
remoteEnabled: true,
remoteAllowPeers: {
[A.info.deviceId]: { terminal: true, forward: true, serial: true },
[B.info.deviceId]: { terminal: true, forward: true, serial: true },
},
})
// ============ 终端 ============
console.log('\x1b[1m[终端]\x1b[0m')
let termSessionId = ''
const TERM_MARK = 'LNM_TERM_SELFTEST'
if (want('terminal') || want('term')) {
await runCase('T1 打开终端: 控制端收到 ack ok', async () => {
const r = await A.terminal.openOnPeer(B.info.deviceId, { rows: 24, cols: 80 })
assert(r.ok, `open 被拒: ${r.reason}`)
assert(!!r.sessionId, '没有 sessionId')
termSessionId = r.sessionId!
})
await runCase('T2 控制端能收到终端输出 (提示符/回显)', async () => {
const got = await waitFor(() => A.hasEvent('terminal:output'), 6000).then(() => true).catch(() => false)
assert(got, '控制端 6s 内没有收到任何 terminal:output 事件 —— 终端画面是空的')
const text = A.findEvents('terminal:output').map(e => decode(e.data!)).join('')
assert(text.length > 0, '收到的输出为空')
})
await runCase('T3 输入命令, 输出能回显到控制端', async () => {
A.clearEvents()
const ok = A.terminal.sendInput(termSessionId, `echo ${TERM_MARK}\n`)
assert(ok, 'sendInput 失败 (会话不存在?)')
await waitFor(() => A.findEvents('terminal:output').map(e => decode(e.data!)).join('').includes(TERM_MARK), 8000)
.catch(() => { throw new Error('8s 内没有看到命令回显 ' + TERM_MARK) })
})
await runCase('T4 窗口尺寸 resize 能传到被控端', async () => {
A.terminal.resize(termSessionId, 40, 120)
await sleep(300)
const sess = B.terminal.listServerSessions().find(s => s.sessionId === termSessionId)
assert(!!sess, '被控端没有这个 session')
assert(sess!.rows === 40 && sess!.cols === 120, `被控端尺寸不对: ${sess!.rows}x${sess!.cols}`)
})
await runCase('T5 关闭终端: 被控端 session 被回收', async () => {
A.terminal.close(termSessionId, 'selftest')
await waitFor(() => B.terminal.listServerSessions().every(s => s.sessionId !== termSessionId), 5000)
.catch(() => { throw new Error('被控端 session 没有被回收') })
})
}
// ============ 端口转发 ============
console.log('\n\x1b[1m[端口转发]\x1b[0m')
if (want('forward') || want('for')) {
const targetPort = 47881 // 被控端的目标服务
const listenPort = 47882 // 控制端的本地监听
const peerListenPort = 47883 // 被控端的监听 (self-in)
const ctrlTargetPort = 47884 // 控制端的目标服务 (self-in)
const peerTarget = await startHttpServer(targetPort, 'HELLO-FROM-PEER')
const ctrlTarget = await startHttpServer(ctrlTargetPort, 'HELLO-FROM-CONTROL')
const openSocks: Socket[] = []
try {
await runCase('F1 self-out: 控制端 127.0.0.1:47882 拿到被控端服务内容', async () => {
const r = await A.forward.openOnPeer(B.info.deviceId, {
direction: 'self-out', listenPort, targetHost: '127.0.0.1', targetPort, ttlSec: 120,
})
assert(r.ok, `open 被拒: ${r.reason}`)
const { body, sock } = await httpGet(listenPort)
openSocks.push(sock)
assert(body.includes('HELLO-FROM-PEER'), `拿到的是: ${body.slice(0, 120)}`)
})
await runCase('F2 self-in: 被控端 127.0.0.1:47883 拿到控制端服务内容', async () => {
const r = await A.forward.openOnPeer(B.info.deviceId, {
direction: 'self-in', listenPort: peerListenPort, targetHost: '127.0.0.1', targetPort: ctrlTargetPort, ttlSec: 120,
})
assert(r.ok, `open 被拒: ${r.reason}`)
const { body, sock } = await httpGet(peerListenPort)
openSocks.push(sock)
assert(body.includes('HELLO-FROM-CONTROL'), `拿到的是: ${body.slice(0, 120)}`)
})
await runCase('F3 拒绝把本机自身端口 (47900) 作为转发目标', async () => {
const r = await A.forward.openOnPeer(B.info.deviceId, {
direction: 'self-out', listenPort: 47890, targetHost: '127.0.0.1', targetPort: 47900, ttlSec: 60,
})
assert(!r.ok, '自身端口竟然被放行了')
})
await runCase('F4 转发字节计数在两端都有记录', async () => {
const cs = A.forward.listClientSessions()
assert(cs.length > 0, '控制端没有转发 session')
const s = cs[0]
assert(s.bytesIn > 0 && s.bytesOut > 0, `字节计数不对: in=${s.bytesIn} out=${s.bytesOut}`)
})
await runCase('F5 关掉"对方发起的"转发: 对端 session 和监听端口都要释放', async () => {
// 由 B 发起: B bind 47885, A 作为 server 侧连 B 填的目标
const r = await B.forward.openOnPeer(A.info.deviceId, {
direction: 'self-out', listenPort: 47885, targetHost: '127.0.0.1', targetPort: targetPort, ttlSec: 300,
})
assert(r.ok, `B 发起转发失败: ${r.reason}`)
const serverSess = A.forward.listServerSessions().find(s => s.sessionId === r.sessionId)
assert(!!serverSess, 'A 侧没有对应的 server session (对方发起的)')
assert(B.forward.listClientSessions().some(s => s.sessionId === r.sessionId), 'B 侧 client session 不存在')
// A 在 UI 上点"关闭" (对方发起的) —— 走 closeSession, 要能关掉 server 侧
const closed = A.forward.closeSession(r.sessionId!, 'user')
assert(closed, 'closeSession 没关掉任何东西')
assert(A.forward.listServerSessions().every(s => s.sessionId !== r.sessionId), 'A 侧 server session 还在')
// 关键: close 帧要发到对端, B 的 client session 及其监听端口要一起回收
await waitFor(() => B.forward.listClientSessions().every(s => s.sessionId !== r.sessionId), 5000)
.catch(() => { throw new Error('对端的 client session 没被回收 (close 帧没发出去?)') })
let portStillOpen = true
try {
await new Promise<void>((res, rej) => {
const sock = connect({ host: '127.0.0.1', port: 47885 }, () => { sock.destroy(); res() })
sock.on('error', () => rej(new Error('refused')))
setTimeout(() => { sock.destroy(); rej(new Error('timeout')) }, 2000)
})
} catch { portStillOpen = false }
assert(!portStillOpen, '对端的监听端口 47885 还占着, 没释放')
})
} finally {
for (const s of openSocks) { try { s.destroy() } catch {} }
peerTarget.close(); ctrlTarget.close()
}
}
// ============ 串口 (映射 + 串口助手) ============
console.log('\n\x1b[1m[串口]\x1b[0m')
if (want('serial') || want('usb')) {
await runCase('S1 列对方串口: 请求-响应链路通', async () => {
const r = await A.serial.listOnPeer(B.info.deviceId)
assert(r.ok, `list 失败: ${r.reason}`)
assert(Array.isArray(r.devices), 'devices 不是数组')
// 只该有串口 —— USB 字节桥/USB-IP 已下线
assert(r.devices!.every(d => !!d.serialPath), '设备项必须有 serialPath')
})
// 串口双向字节流: 用 socat 造一个 PTY 对冒充"真串口"
const vcomDir = join(tmpdir(), `lnm-selftest-vcom-${Date.now()}`)
mkdirSync(vcomDir, { recursive: true })
let socatProc: ChildProcess | null = null
let devEnd: ReturnType<typeof openPty> | null = null
try {
const pair = await startSocatSerial(vcomDir)
socatProc = pair.proc
// 被控端持有 "user" 端 (当真实串口), 测试这边拿 "internal" 端当外部设备
B.serial.listLocal = async () => ([{
busId: `serial:${pair.userPath}`, serialPath: pair.userPath,
vid: 0, pid: 0, product: 'selftest-pty', manufacturer: 'socat', baudRate: 115200,
}] as any)
await runCase('S2 attach 串口: 控制端能拿到 attached ok', async () => {
const r = await A.serial.attach(B.info.deviceId, {
busId: `serial:${pair.userPath}`, direction: 'self-out',
config: { kind: 'serial', baudRate: 115200 },
})
assert(r.ok, `attach 被拒: ${r.reason}`)
})
await runCase('S3 串口双向字节流: 控制端 -> 设备', async () => {
const sess = A.serial.listClientSessions()[0]
assert(!!sess, '控制端没有 session')
devEnd = openPty(pair.internalPath)
const ok = await A.serial.sendSerialBytes(sess.sessionId, Buffer.from('HELLO-FROM-CONTROL\n'))
assert(ok, 'sendSerialBytes 失败')
// 设备端应该收到
let seen = ''
for (let i = 0; i < 50 && !seen.includes('HELLO-FROM-CONTROL'); i++) {
await sleep(100)
seen += devEnd.read()
}
assert(seen.includes('HELLO-FROM-CONTROL'), `设备端没收到: ${JSON.stringify(seen)}`)
})
await runCase('S4 串口双向字节流: 设备 -> 控制端 (RX)', async () => {
assert(!!devEnd, '设备端没准备好')
A.clearEvents()
devEnd.write('HELLO-FROM-DEVICE\n')
await waitFor(() => A.findEvents('serial:output').map(e => decode(e.data!)).join('').includes('HELLO-FROM-DEVICE'), 6000)
.catch(() => { throw new Error('控制端没收到设备发来的字节') })
// 方向必须标成 rx —— 串口助手要靠它区分"我发的"和"设备回的"
const ev = A.findEvents('serial:output').find(e => decode(e.data!).includes('HELLO-FROM-DEVICE'))
assert(ev && (ev as any).dir === 'rx', `方向应为 rx, 实际 ${(ev as any)?.dir}`)
})
await runCase('S5 串口助手: 发出去的字节不会被当成 rx 回显 (不回环, 否则助手的收发会混淆)', async () => {
A.clearEvents()
const sess = A.serial.listClientSessions()[0]
await A.serial.sendSerialBytes(sess.sessionId, Buffer.from('PING'))
// 设备端应该收到
let seen = ''
for (let i = 0; i < 30 && !seen.includes('PING'); i++) {
await sleep(100)
seen += devEnd!.read()
}
assert(seen.includes('PING'), `设备端没收到: ${JSON.stringify(seen)}`)
// 我发出去的字节不该在我这边又冒出来 (那会被误显示成"设备回的")
const echoed = A.findEvents('serial:output').map(e => decode(e.data!)).join('')
assert(!echoed.includes('PING'), `发出去的字节被回显了: ${JSON.stringify(echoed)}`)
})
await runCase('S6 断开: 两端 session 都被清掉', async () => {
const sess = A.serial.listClientSessions()[0]
assert(!!sess, '没有 session 可断')
const r = await A.serial.detach(sess.sessionId, 'test')
assert(r.ok, 'detach 失败')
await sleep(300)
assert(A.serial.listClientSessions().length === 0, '控制端 session 没清掉')
})
} finally {
try { devEnd?.close() } catch {}
try { socatProc?.kill() } catch {}
try { rmSync(vcomDir, { recursive: true, force: true }) } catch {}
}
// ---- 串口映射 (本机虚拟串口) ----
if (want('vcom')) {
const vcomDir2 = join(tmpdir(), `lnm-selftest-vcom2-${Date.now()}`)
mkdirSync(vcomDir2, { recursive: true })
let socatProc2: ChildProcess | null = null
try {
const pair = await startSocatSerial(vcomDir2)
socatProc2 = pair.proc
B.serial.listLocal = async () => ([{
busId: `serial:${pair.userPath}`, serialPath: pair.userPath,
product: 'selftest-pty', baudRate: 115200,
}] as any)
await runCase('S7 串口映射: 控制端建出本机虚拟串口 (socat PTY)', async () => {
const r = await A.serial.attach(B.info.deviceId, {
busId: `serial:${pair.userPath}`, direction: 'self-out',
config: { kind: 'serial', baudRate: 115200, createVirtual: true },
})
assert(r.ok, `attach 被拒: ${r.reason}`)
const sess = A.serial.listClientSessions()[0]
const vpath = sess?.info?.userVirtualPath
assert(!!vpath, '没拿到本机虚拟串口路径')
assert(existsSync(vpath!), `虚拟串口路径不存在: ${vpath}`)
console.log(` (虚拟串口: ${vpath})`)
})
await runCase('S7b 串口映射: 往虚拟口写的字节方向标记为 tx', async () => {
const sess = A.serial.listClientSessions()[0]
const vpath = sess?.info?.userVirtualPath!
A.clearEvents()
// 模拟"用户工具往虚拟口写数据"
const fd = openSync(vpath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK)
try { writeSync(fd, 'FROM-USER-TOOL') } finally { try { closeSync(fd) } catch {} }
await waitFor(() => A.findEvents('serial:output').length > 0, 5000)
.catch(() => { throw new Error('虚拟口写入没产生字节流事件') })
const ev = A.findEvents('serial:output')[0] as any
assert(ev.dir === 'tx', `方向应为 tx, 实际 ${ev.dir}`)
assert(decode(ev.data!).includes('FROM-USER-TOOL'), '内容不对')
})
await runCase('S8 串口映射: 远端字节能流到本机虚拟口 (给 PuTTY/screen 用)', async () => {
const sess = A.serial.listClientSessions()[0]
const vpath = sess?.info?.userVirtualPath!
// 从被控端的"真实串口"灌一串数据, 看它有没有出现在本机虚拟口
const dev = openPty(pair.internalPath)
try {
dev.write('THROUGH-VCOM\n')
await waitFor(() => {
const fd = openSync(vpath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK)
try {
const buf = Buffer.alloc(256)
const n = readSync(fd, buf, 0, buf.length, null)
return n > 0 && buf.slice(0, n).toString('utf8').includes('THROUGH-VCOM')
} catch { return false } finally { try { closeSync(fd) } catch {} }
}, 6000).catch(() => { throw new Error('字节没流到本机虚拟串口') })
} finally {
dev.close()
}
})
} finally {
try { socatProc2?.kill() } catch {}
try { rmSync(vcomDir2, { recursive: true, force: true }) } catch {}
}
}
await runCase('S9 串口错误提示: 打不开设备时要能指导用户 (Linux 权限/占用/不存在)', async () => {
const { describeOpenError } = await import('../../src/main/remote/serial')
const perm = describeOpenError('/dev/ttyUSB0', new Error('EACCES: permission denied'))
assert(/dialout/.test(perm), `Linux 权限错误应提示 dialout, 实际: ${perm}`)
const busy = describeOpenError('/dev/ttyUSB0', new Error('Resource temporarily unavailable'))
assert(/占用/.test(busy), `占用错误应提示"被别的程序占用", 实际: ${busy}`)
const gone = describeOpenError('/dev/ttyUSB0', new Error('ENOENT: no such file or directory'))
assert(/不存在|拔出/.test(gone), `不存在错误应提示设备没了, 实际: ${gone}`)
})
}
// ============ 网盘 ============
console.log('\n\x1b[1m[网盘]\x1b[0m')
if (want('drive') || want('网盘')) {
await runCase('D1 列对方网盘根目录', async () => {
const r = await listDrive(B.info, '')
assert(r.ok, `列目录失败: ${r.reason}`)
const names = (r.entries || []).map(e => e.name)
assert(names.includes('hello.txt'), `没看到 hello.txt, 实际: ${names.join(',')}`)
assert(names.includes('sub'), `没看到子目录 sub, 实际: ${names.join(',')}`)
const f = (r.entries || []).find(e => e.name === 'hello.txt')!
assert(f.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${f.size}`)
assert(!f.isDir, '文件被标成目录了')
})
await runCase('D2 进子目录再列一次', async () => {
const r = await listDrive(B.info, 'sub')
assert(r.ok, `列子目录失败: ${r.reason}`)
const names = (r.entries || []).map(e => e.name)
assert(names.includes('nested.txt'), `实际: ${names.join(',')}`)
})
await runCase('D3 下载文件: 内容一致', async () => {
const dest = join(tmpdir(), `lnm-selftest-dl-${Date.now()}`)
mkdirSync(dest, { recursive: true })
const r = await downloadDriveFile({ peer: B.info, relPath: 'hello.txt', destDir: dest })
assert(r.ok, `下载失败: ${r.reason}`)
const content = readFileSync(r.path!, 'utf8')
assert(content === 'HELLO-FROM-DRIVE', `内容不对: ${content}`)
assert(r.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${r.size}`)
})
await runCase('D4 下载子目录里的文件', async () => {
const dest = join(tmpdir(), `lnm-selftest-dl2-${Date.now()}`)
mkdirSync(dest, { recursive: true })
const r = await downloadDriveFile({ peer: B.info, relPath: 'sub/nested.txt', destDir: dest })
assert(r.ok, `下载失败: ${r.reason}`)
assert(readFileSync(r.path!, 'utf8') === 'NESTED-FROM-DRIVE', '内容不对')
})
await runCase('D5 目录穿越被挡住 (.. 逃不出网盘根)', async () => {
const r = await listDrive(B.info, '../../../../etc')
assert(!r.ok, '竟然列出了网盘之外的目录!')
const r2 = await listDrive(B.info, '..')
assert(!r2.ok, '竟然能列出上级目录!')
})
await runCase('D6 token 错误 -> 拒绝访问', async () => {
const bad = { ...B.info, driveToken: 'wrong-token' }
const r = await listDrive(bad, '')
assert(!r.ok, '错误 token 竟然能访问!')
assert(/令牌/.test(r.reason || ''), `错误信息不对: ${r.reason}`)
})
await runCase('D7 对方没开网盘 (无 token) -> 明确提示不支持', async () => {
const r = await listDrive({ ...B.info, driveToken: undefined }, '')
assert(!r.ok, '无 token 竟然能访问!')
assert(/不支持/.test(r.reason || ''), `错误信息不对: ${r.reason}`)
})
await runCase('D8 递归收集目录下的全部文件', async () => {
const r = await collectDriveFiles(B.info, 'sub')
assert(r.ok, `收集失败: ${r.reason}`)
const names = r.files.map(f => f.relPath).sort()
assert(names.includes('sub/nested.txt'), `缺 nested.txt: ${names.join(',')}`)
assert(names.includes('sub/deep/deeper.txt'), `缺深层文件: ${names.join(',')}`)
})
await runCase('D9 整目录下载: 保持层级', async () => {
const dest = join(tmpdir(), `lnm-selftest-dl3-${Date.now()}`)
mkdirSync(dest, { recursive: true })
const collected = await collectDriveFiles(B.info, 'sub')
assert(collected.ok, '收集失败')
for (const f of collected.files) {
const relInside = f.relPath.slice('sub'.length).replace(/^[/\\]+/, '')
const dirInside = relInside ? dirname(relInside) : ''
const destSubPath = dirInside && dirInside !== '.' ? join('sub', dirInside) : 'sub'
const r = await downloadDriveFile({ peer: B.info, relPath: f.relPath, destDir: dest, destSubPath })
assert(r.ok, `下载 ${f.relPath} 失败: ${r.reason}`)
}
assert(existsSync(join(dest, 'sub', 'nested.txt')), '层级不对: 少了 sub/nested.txt')
assert(existsSync(join(dest, 'sub', 'deep', 'deeper.txt')), '层级不对: 少了 sub/deep/deeper.txt')
assert(readFileSync(join(dest, 'sub', 'deep', 'deeper.txt'), 'utf8') === 'DEEPER-FROM-DRIVE', '内容不对')
})
}
// ============ 消息通道状态 ============
// 回归: 启动时主进程先连上对端, 渲染层后订阅 -> open 事件被丢掉, UI 永久显示"等待重连"
// 所以状态必须是"拉取式"的: 任何时刻去查都能查到当前真实状态
if (want('ws') || want('state')) {
console.log('\n\x1b[1m[通道状态]\x1b[0m')
await runCase('W1 通道状态可随时拉取 (连接早已建好, 现在才查也查得到)', async () => {
assert(A.client.isOpen(B.info.deviceId), '前提不成立: A->B 的 outgoing 没开')
// 模拟"渲染层晚订阅": 此刻才去查状态
const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server)
assert(st[B.info.deviceId] === true, `状态不对: ${JSON.stringify(st)}`)
})
await runCase('W2 只有 inbound 通 (对方连我, 我没主动连) 也算通道可用', async () => {
const C = new Node('peer-cccccccc-0000-4000-8000-00000000000c', '只入站C')
await C.start()
try {
B.connectTo(C) // 只有 B -> C 这一条腿
await waitFor(() => C.server.isConnected(B.info.deviceId), 8000)
assert(!C.client.isOpen(B.info.deviceId), '前提不成立: C 不该有 outgoing 连接')
const st = computeWsState([B.info.deviceId], C.client.getStatus(), C.server)
assert(st[B.info.deviceId] === true, `只有 inbound 通时不该判死: ${JSON.stringify(st)}`)
} finally {
await C.stop()
}
})
await runCase('W3 两条腿都断了 -> 状态回落 false', async () => {
A.client.remove(B.info.deviceId)
B.client.remove(A.info.deviceId)
await waitFor(() => !A.server.isConnected(B.info.deviceId), 5000)
await waitFor(() => !B.server.isConnected(A.info.deviceId), 5000)
const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server)
assert(st[B.info.deviceId] === false, `断了还报可用: ${JSON.stringify(st)}`)
})
}
// ============ 跨平台兼容 ============
if (want('win') || want('compat') || want('platform')) {
console.log('\n\x1b[1m[跨平台兼容]\x1b[0m')
await runCase('S1 Windows 保留设备名被改写 (CON/NUL/COM1 带扩展名也算)', async () => {
for (const n of ['CON', 'con.txt', 'NUL', 'AUX', 'COM1', 'LPT1', 'clock$.log']) {
const out = safeFileName(n, { platform: 'win32' })
assert(!/^(CON|NUL|AUX|COM1|LPT1|CLOCK\$)/i.test(out), `"${n}" 没被处理: ${out}`)
assert(out.startsWith('_'), `"${n}" 应该加前缀: ${out}`)
}
})
await runCase('S2 Windows 尾部点/空格被去掉 (Win32 会悄悄吞掉它们)', async () => {
assert(safeFileName('report.', { platform: 'win32' }) === 'report', '尾部点没去掉')
assert(safeFileName('report ', { platform: 'win32' }) === 'report', '尾部空格没去掉')
assert(safeFileName('a b ', { platform: 'win32' }) === 'a b', '不该动中间的空格')
})
await runCase('S3 非法字符全平台都清 (含路径分隔符)', async () => {
for (const plat of ['win32', 'linux', 'darwin'] as NodeJS.Platform[]) {
const out = safeFileName('a/b\\c:d*e?f"g<h>i|j', { platform: plat })
assert(!/[/\\:*?"<>|]/.test(out), `${plat} 上还有非法字符: ${out}`)
}
})
await runCase('S4 Linux/macOS 不乱改合法文件名 (CON 在 Linux 上是合法文件)', async () => {
assert(safeFileName('CON', { platform: 'linux' }) === 'CON', 'Linux 上不该改 CON')
assert(safeFileName('a:b', { platform: 'darwin' }) === 'a_b', '冒号还是该清')
})
await runCase('S5 超长文件名被截断且不超上限', async () => {
const out = safeFileName('x'.repeat(500) + '.txt', { platform: 'linux' })
assert(out.length <= 200, `没截断: ${out.length}`)
assert(out.endsWith('.txt'), '扩展名丢了')
})
await runCase('S6 默认 shell / cwd 可用', async () => {
const sh = defaultShell()
assert(sh && sh.file, '没选到 shell')
const cwd = defaultCwd()
assert(cwd && existsSync(cwd), `cwd 不存在: ${cwd}`)
})
await runCase('S7 打包配置: node-pty 平台包必须递归解包 (否则 Windows 终端起不来)', async () => {
// 打包后 __dirname 在 scripts/selftest/.out, 向上找到带 build.asarUnpack 的 package.json
let dir = __dirname
let pkg: any = null
for (let i = 0; i < 6; i++) {
const f = join(dir, 'package.json')
if (existsSync(f)) {
try {
const j = JSON.parse(readFileSync(f, 'utf8'))
if (j?.build?.asarUnpack) { pkg = j; break }
} catch {}
}
dir = dirname(dir)
}
assert(pkg, '没找到带 build.asarUnpack 的 package.json')
const pats: string[] = pkg.build.asarUnpack || []
const ptyPat = pats.find(p => p.includes('@lydell/node-pty-*'))
assert(ptyPat, `asarUnpack 里没有 node-pty 平台包的规则: ${pats.join(' | ')}`)
assert(
ptyPat.endsWith('/**/*'),
`node-pty 平台包规则必须递归 (/**/*), 否则 prebuilds/<plat>/ 里的 .node/.dll/.exe 会留在 asar 里: ${ptyPat}`,
)
})
await runCase('S8 上传落盘: 文件名按本机平台清洗, 且不逃出接收目录', async () => {
const root = join(tmpdir(), `lnm-selftest-up-${Date.now()}`)
mkdirSync(root, { recursive: true })
const fsrv = new FileServer(0, root, {
getDriveRoot: () => '', getDriveToken: () => '', isDriveEnabled: () => false,
})
const port = await fsrv.start()
const upload = async (name: string, body: string) => {
const buf = Buffer.from(body)
return new Promise<any>((resolve, reject) => {
const req = httpRequest({
host: '127.0.0.1', port, path: '/upload', method: 'POST',
headers: {
'x-file-id': `selftest-up-${Math.random().toString(36).slice(2)}`,
'x-file-name': encodeURIComponent(name),
'x-file-size': String(buf.length),
'x-file-mime': 'text/plain',
'Content-Length': String(buf.length),
},
}, r => {
let b = ''
r.on('data', d => (b += d))
r.on('end', () => { try { resolve(JSON.parse(b)) } catch (e) { reject(e) } })
})
req.on('error', reject)
req.end(buf)
})
}
try {
// 1) Windows 保留设备名: 在 Linux 上是合法文件名, 不该被改;
// 在 Windows 上必须被改写 (S1 已覆盖 win32 分支)。这里校验"落盘名 == 清洗结果"。
const r1 = await upload('CON.txt', 'UPLOAD_BODY')
assert(r1?.ok, `上传失败: ${JSON.stringify(r1)}`)
const saved1: string = r1.savedPath
assert(existsSync(saved1), `文件没落盘: ${saved1}`)
assert(readFileSync(saved1, 'utf8') === 'UPLOAD_BODY', '内容不对')
const expect1 = safeFileName('CON.txt', { platform: process.platform })
assert((saved1.split(/[\\/]/).pop() || '') === expect1,
`落盘名与清洗结果不一致: ${saved1} vs ${expect1}`)
// 2) 路径穿越: 文件名里带 ../ 必须被清洗掉, 不能写到接收目录外面
const r2 = await upload('../../evil.txt', 'EVIL')
assert(r2?.ok, `上传失败: ${JSON.stringify(r2)}`)
const saved2: string = r2.savedPath
assert(existsSync(saved2), `文件没落盘: ${saved2}`)
const rel = saved2.slice(root.length).replace(/^[\\/]+/, '')
assert(!rel.split(/[\\/]/).includes('..'), `路径穿越没挡住: ${saved2}`)
assert(!saved2.includes(`${root}..`), `路径穿越没挡住: ${saved2}`)
} finally {
await fsrv.stop()
try { rmSync(root, { recursive: true, force: true }) } catch {}
}
})
}
// ============ 关系 (同步盘授权) ============
console.log('\n\x1b[1m[关系]\x1b[0m')
// A-B: 主关系 (R1 建的, 后面 M 组要用); A-E: 只给 sync 授权的关系 (R4 建的, M8 多关系隔离要用)
let relAB = ''
let relAE = ''
if (want('relation') || want('rel')) {
// W3 那组把 A-B 的连接掐了, 这里重新连上 (真实客户端断了也会重连)
await link(A, B)
await link(A, E)
await link(A, F)
await link(A, G)
await runCase('R1 建立关系: 发起 -> 对方弹窗 -> 同意 -> 双方关系落库且 active, 令牌互换', async () => {
const { relationId, fromRel, toRel } = await establishRelation(A, B)
relAB = relationId
assert(fromRel.state === 'active', `发起方关系状态不对: ${fromRel.state}`)
assert(toRel.state === 'active', `对方关系状态不对: ${toRel.state}`)
assert(!!fromRel.myToken && !!fromRel.peerToken, '令牌没互换 (有一边是空的)')
assert(fromRel.myToken !== fromRel.peerToken, '两个令牌是同一个 (不该是)')
assert(toRel.myToken === fromRel.peerToken, '对方签发的令牌对不上')
assert(toRel.peerToken === fromRel.myToken, '对方拿到的令牌对不上')
assert(toRel.syncLocalDir === B.syncLocalDir, '对方的同步目录没落库')
assert(fromRel.peerGrants.includes('sync') && toRel.peerGrants.includes('sync'), '授权能力没互换')
})
await runCase('R2 对方拒绝: 发起方的 pending 关系被清理', async () => {
const r = A.relationService.requestRelation({
peerId: F.info.deviceId, peerName: F.info.name, peerAddress: F.info.address,
peerChatPort: F.info.chatPort, peerFilePort: F.info.filePort,
syncLocalDir: A.syncLocalDir, syncRemotePath: '同步/F', grants: ['drive', 'sync'],
})
assert(r.ok, `发起失败: ${r.reason}`)
assert(A.relations.getRelation(r.relationId!)?.state === 'pending', '发起方没有落 pending 关系')
const req = await waitForItem(
() => listPendingApprovals().find(x => x.kind === 'relation' && x.peerId === A.info.deviceId),
).catch(() => { throw new Error('对方没有弹出关系授权窗口') })
assert(replyApproval(req.requestId, false), '回复拒绝失败')
await waitFor(() => !A.relations.getRelation(r.relationId!), 5000)
.catch(() => { throw new Error('对方拒绝后, 发起方的 pending 关系还在') })
})
await runCase('R3 per-peer 令牌: 关系令牌能访问对方网盘; 错令牌 403; 令牌与 peerId 不匹配 403', async () => {
const rel = A.relations.getRelation(relAB)
assert(!!rel, '前置条件不成立: A-B 关系不存在')
const token = rel!.peerToken
assert(!!token, '关系里没有对方的令牌')
// 1) 关系里的对方令牌 -> 能访问对方网盘
const ok = await listDrive({ ...B.info, driveToken: token }, '')
assert(ok.ok, `用关系令牌访问对方网盘失败: ${ok.reason}`)
assert((ok.entries || []).some(e => e.name === 'hello.txt'), '列出来的不是对方的网盘内容')
// 2) 错令牌 -> 403
const bad = await httpJson(B.info.filePort, `/drive/scan?token=wrong-token&peerId=${encodeURIComponent(A.info.deviceId)}`)
assert(bad.status === 403, `错令牌应该 403, 实际 ${bad.status}`)
// 3) 令牌跟 peerId 对不上 -> 403 (这枚令牌是发给 A 的, 不能拿给别的设备用)
const mismatch = await httpJson(B.info.filePort,
`/drive/scan?token=${encodeURIComponent(token)}&peerId=${encodeURIComponent(F.info.deviceId)}`)
assert(mismatch.status === 403, `令牌与设备不匹配应该 403, 实际 ${mismatch.status}`)
})
await runCase('R4 授权分级: 只给 sync 时网盘 403 (/drive/scan), 但同步区能访问 (/sync/scan 200)', async () => {
const { relationId } = await establishRelation(A, E, { grants: ['drive', 'sync'], peerGrants: ['sync'] })
relAE = relationId
const rel = A.relations.getRelation(relationId)!
const q = `token=${encodeURIComponent(rel.peerToken)}&peerId=${encodeURIComponent(A.info.deviceId)}`
// 网盘: 没给 drive -> 403
const drive = await httpJson(E.info.filePort, `/drive/scan?${q}`)
assert(drive.status === 403, `只给 sync 时网盘应该 403, 实际 ${drive.status}`)
// 同步区: 给了 sync -> 200
const sync = await httpJson(E.info.filePort, `/sync/scan?${q}&relationId=${encodeURIComponent(relationId)}&hash=1`)
assert(sync.status === 200, `只给 sync 时同步区应该 200, 实际 ${sync.status}`)
assert(sync.data?.ok === true, `同步区返回不对: ${sync.text.slice(0, 120)}`)
})
await runCase('R5 吊销关系后: 旧令牌立刻失效 (403)', async () => {
// 注意: 用独立的节点 G —— 对方对同一对端 60s 内只弹一次窗 (防骚扰),
// R2 刚用过 F, 再用 F 会被直接回绝 (请求过于频繁)
const { relationId } = await establishRelation(A, G)
const gRel = G.relations.getRelation(relationId)!
const oldToken = gRel.peerToken // G 手里能访问 A 网盘的那枚 (= A 的 myToken)
assert(!!oldToken, '关系里没有令牌')
const before = await httpJson(A.info.filePort,
`/drive/scan?token=${encodeURIComponent(oldToken)}&peerId=${encodeURIComponent(G.info.deviceId)}`)
assert(before.status === 200, `吊销前应该能访问对方网盘, 实际 ${before.status}`)
const rev = A.relationService.revokeRelation(relationId)
assert(rev.ok, '吊销失败')
const after = await httpJson(A.info.filePort,
`/drive/scan?token=${encodeURIComponent(oldToken)}&peerId=${encodeURIComponent(G.info.deviceId)}`)
assert(after.status === 403, `吊销后旧令牌应该 403, 实际 ${after.status}`)
})
await runCase('R6 关系是持久化的: 重新 new 一个 RelationStore 读同一个文件, 关系还在', async () => {
const rel = A.relations.getRelation(relAB)
assert(!!rel, '前置条件不成立: A-B 关系不存在')
const store2 = new RelationStore(A.relationsFile)
const again = store2.getRelation(relAB!)
assert(!!again, '重新打开关系库文件后, 关系不见了')
assert(again!.state === 'active', `状态不对: ${again!.state}`)
assert(again!.peerId === B.info.deviceId, '对方的 deviceId 不对')
assert(!!again!.myToken && !!again!.peerToken, '令牌丢了')
assert(!!store2.getActiveRelationByPeer(B.info.deviceId), '按 peerId 查不到关系')
})
}
// ============ 文件关系表 / 删除传播 ============
console.log('\n\x1b[1m[文件关系表 / 删除传播]\x1b[0m')
if (want('manifest') || want('sync-manifest')) {
// 关系是前置条件: 单独跑这一组时自己建一条
let mSetupErr = ''
try {
await link(A, B)
await link(A, E)
if (!A.relations.getActiveRelationByPeer(B.info.deviceId)) {
relAB = (await establishRelation(A, B)).relationId
}
if (!A.relations.getActiveRelationByPeer(E.info.deviceId)) {
relAE = (await establishRelation(A, E, { grants: ['drive', 'sync'], peerGrants: ['sync'] })).relationId
}
} catch (e: any) {
mSetupErr = String(e?.message || e)
}
const needSetup = () => assert(!mSetupErr && !!relAB, `前置条件不成立: ${mSetupErr || 'A-B 关系没建起来'}`)
// 准备文件: A 有 a.txt / shared.txt, B 有 b.txt
writeFileSync(join(A.syncRoot, 'a.txt'), 'A-CONTENT')
writeFileSync(join(A.syncRoot, 'shared.txt'), 'FROM-A')
writeFileSync(join(B.syncRoot, 'b.txt'), 'B-CONTENT')
await runCase('M1 首次同步建立基线: A 的文件上传到 B, B 的文件下载到 A, 两边内容一致', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const items = scan.items
.filter(i => i.action === 'upload' || i.action === 'download')
.map(i => ({ relPath: i.relPath, action: i.action as 'upload' | 'download' }))
const r = await applySyncItems({ ...ctx, items })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
// A 的文件到了 B
assert(existsSync(join(B.syncRoot, 'a.txt')), 'a.txt 没传到对方')
assert(readText(join(B.syncRoot, 'a.txt')) === 'A-CONTENT', 'a.txt 内容不对')
assert(existsSync(join(B.syncRoot, 'shared.txt')), 'shared.txt 没传到对方')
assert(readText(join(B.syncRoot, 'shared.txt')) === 'FROM-A', 'shared.txt 内容不对')
// B 的文件到了 A
assert(existsSync(join(A.syncRoot, 'b.txt')), 'b.txt 没下载到本地')
assert(readText(join(A.syncRoot, 'b.txt')) === 'B-CONTENT', 'b.txt 内容不对')
// 基线建起来了 (后面判"谁改了"全靠它)
const man = A.manifestStore.loadManifest(relAB)
assert(!!man.entries['a.txt']?.baseHash, 'a.txt 没有基线')
assert(!!man.entries['b.txt']?.baseHash, 'b.txt 没有基线')
})
await runCase('M2 本地改了文件: 判定为 upload (不是 conflict) -> 同步后对方内容更新', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
writeFileSync(join(A.syncRoot, 'a.txt'), 'A-EDITED')
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'a.txt')
assert(!!it, 'a.txt 没出现在扫描结果里')
assert(it!.action === 'upload', `本地改过应判 upload, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'a.txt', action: 'upload' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(readText(join(B.syncRoot, 'a.txt')) === 'A-EDITED', '对方内容没更新')
})
await runCase('M3 本地删了文件: 判定为 delete-remote -> 同步后对方那份也没了 (删除传播)', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
rmSync(join(A.syncRoot, 'a.txt'), { force: true })
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'a.txt')
assert(!!it, 'a.txt 没出现在扫描结果里')
assert(it!.action === 'delete-remote', `本地删了应判 delete-remote, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'a.txt', action: 'delete-remote' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(!existsSync(join(B.syncRoot, 'a.txt')), '对方那份还在 (删除没传播)')
})
await runCase('M4 对方删了文件: 判定为 delete-local -> 同步后本地那份也没了', async () => {
needSetup()
const ctxA = syncCtx(A, B, relAB)
const ctxB = syncCtx(B, A, relAB)
// 对方先删掉, 再扫描一次记墓碑 (这一步不 apply —— 删除是通过关系表传播的)
rmSync(join(B.syncRoot, 'b.txt'), { force: true })
const scanB = await scanSync(ctxB)
assert(scanB.ok, `对方扫描失败: ${scanB.reason}`)
// 我这边扫描 -> 从对方的关系表里看到墓碑 -> 判 delete-local
const scanA = await scanSync(ctxA)
assert(scanA.ok, `扫描失败: ${scanA.reason}`)
const it = scanA.items.find(i => i.relPath === 'b.txt')
assert(!!it, 'b.txt 没出现在扫描结果里')
assert(it!.action === 'delete-local', `对方删了应判 delete-local, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctxA, items: [{ relPath: 'b.txt', action: 'delete-local' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(!existsSync(join(A.syncRoot, 'b.txt')), '本地那份还在 (删除没传播)')
})
await runCase('M5 删除不会被撤销: 删掉并同步后再扫一次, 不会被当成"对方新增"重新下载 (墓碑生效)', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'b.txt')
assert(!!it, 'b.txt 没出现在扫描结果里')
assert(it!.action !== 'download', `墓碑失效, 被当成对方新增要重新下载: ${it!.action}`)
assert(!existsSync(join(A.syncRoot, 'b.txt')), '本地文件复活了')
// 墓碑还在关系表里
const e = A.manifestStore.loadManifest(relAB).entries['b.txt']
assert(!!e, '关系表里没有 b.txt 的记录')
assert(e!.myState === 'deleted', `墓碑状态不对: ${e!.myState}`)
assert(e!.mySeq > 0, '墓碑没有序号')
})
await runCase('M6 复活防护: 对方拿一个更早的 seq 声称文件还在 -> 墓碑仍然赢 (保持删除), 不会被复活', async () => {
// 单元级: 构造一个"我删了"的墓碑, 对方拿更早的序号声称文件还在
const store = new ManifestStore(join(tmpdir(), `lnm-selftest-m6-${Date.now()}.json`))
const man: Manifest = store.loadManifest('rel-m6')
const H = 'a'.repeat(64)
man.clock = 10
man.entries['z.txt'] = {
fileId: 'file-m6', relPath: 'z.txt',
baseHash: H, baseSize: 1, baseMtime: 0,
myHash: H, mySize: 1, myMtime: 0, myState: 'present', mySeq: 1,
peerHash: H, peerSize: 1, peerMtime: 0, peerState: 'present', peerSeq: 9,
deletedAt: 0,
}
markLocalDeleted(man, 'z.txt') // 我这边删了 -> 记墓碑 (mySeq = 11)
const mySeq = man.entries['z.txt'].mySeq
assert(mySeq > 10, `墓碑序号不对: ${mySeq}`)
// 对方拿一个**更早**的序号 (5) 声称文件还在
const older = new Map<string, ManifestWireEntry>([['z.txt', {
fileId: 'file-m6', relPath: 'z.txt', hash: H, size: 1, mtime: 0,
state: 'present', seq: 5, deletedAt: 0,
}]])
mergePeer(man, older)
const it = planSync(man, [], older).items.find(i => i.relPath === 'z.txt')!
assert(it.action !== 'download', `墓碑失效, 文件被当成新增要下载: ${it.action}`)
assert(it.action === 'delete-remote', `墓碑应该赢 (删除继续传播), 实际 ${it.action}`)
// 对方用**更新**的序号声称恢复 -> 才允许复活
const newer = new Map<string, ManifestWireEntry>([['z.txt', { ...older.get('z.txt')!, seq: mySeq + 1 }]])
const it2 = planSync(man, [], newer).items.find(i => i.relPath === 'z.txt')!
assert(it2.action === 'download', `对方序号更新应该允许恢复, 实际 ${it2.action}`)
})
await runCase('M7 冲突: 两边都改了同一个文件 -> 判 conflict, apply 不会静默覆盖任一边', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
writeFileSync(join(A.syncRoot, 'shared.txt'), 'FROM-A-V2')
writeFileSync(join(B.syncRoot, 'shared.txt'), 'FROM-B-V2')
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'shared.txt')
assert(!!it, 'shared.txt 没出现在扫描结果里')
assert(it!.action === 'conflict', `两边都改过应判 conflict, 实际 ${it!.action}`)
// apply 里没有 conflict 这个动作 (只能 skip) -> 两边都不该被覆盖
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'shared.txt', action: 'skip' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(readText(join(A.syncRoot, 'shared.txt')) === 'FROM-A-V2', '本地被覆盖了')
assert(readText(join(B.syncRoot, 'shared.txt')) === 'FROM-B-V2', '对方被覆盖了')
})
await runCase('M8 多关系隔离: 同一个本地目录跟两个关系同步, 基线/墓碑互不串 (key 是 relationId)', async () => {
needSetup()
assert(!!relAE, '前置条件不成立: A-E 关系没建起来')
const ctxB = syncCtx(A, B, relAB)
const ctxE = syncCtx(A, E, relAE)
// 专门用一个干净的文件测隔离 (iso.txt)
writeFileSync(join(A.syncRoot, 'iso.txt'), 'ISO-CONTENT')
// 先跟 E 同步一轮 -> E 这条关系也有 iso.txt 的基线
const scanE = await scanSync(ctxE)
assert(scanE.ok, `扫描失败: ${scanE.reason}`)
const upE = scanE.items
.filter(i => i.action === 'upload')
.map(i => ({ relPath: i.relPath, action: 'upload' as const }))
assert(upE.some(i => i.relPath === 'iso.txt'), 'E 关系里没有 iso.txt 的上传项')
const rE = await applySyncItems({ ...ctxE, items: upE })
assert(rE.ok, `同步失败: ${JSON.stringify(rE.results)}`)
// 再跟 B 同步一轮 -> B 这条关系也有 iso.txt 的基线
const scanB1 = await scanSync(ctxB)
assert(scanB1.ok, `扫描失败: ${scanB1.reason}`)
const upB = scanB1.items
.filter(i => i.action === 'upload')
.map(i => ({ relPath: i.relPath, action: 'upload' as const }))
assert(upB.some(i => i.relPath === 'iso.txt'), 'B 关系里没有 iso.txt 的上传项')
const rB = await applySyncItems({ ...ctxB, items: upB })
assert(rB.ok, `同步失败: ${JSON.stringify(rB.results)}`)
assert(!!A.manifestStore.loadManifest(relAE).entries['iso.txt']?.baseHash, 'E 关系里没有 iso.txt 的基线')
assert(!!A.manifestStore.loadManifest(relAB).entries['iso.txt']?.baseHash, 'B 关系里没有 iso.txt 的基线')
// 现在本地删掉 iso.txt, 只跟 B 这条关系同步
rmSync(join(A.syncRoot, 'iso.txt'), { force: true })
const scanB2 = await scanSync(ctxB)
assert(scanB2.ok, `扫描失败: ${scanB2.reason}`)
const it = scanB2.items.find(i => i.relPath === 'iso.txt')
assert(!!it, 'iso.txt 没出现在扫描结果里')
assert(it!.action === 'delete-remote', `本地删了应判 delete-remote, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctxB, items: [{ relPath: 'iso.txt', action: 'delete-remote' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(!existsSync(join(B.syncRoot, 'iso.txt')), '对方那份还在')
// B 关系的墓碑不能串到 E 关系上
const manB = A.manifestStore.loadManifest(relAB)
const manE = A.manifestStore.loadManifest(relAE)
assert(manB.entries['iso.txt']?.myState === 'deleted', 'B 关系里没有墓碑')
assert(manE.entries['iso.txt']?.myState !== 'deleted', 'E 关系被 B 的墓碑污染了')
assert(manB.relationId !== manE.relationId, '两个关系用了同一个 key')
})
await runCase('M9 目录穿越: 同步路径里带 ../ 必须被拒绝 (本地和远端都不许写到目录外)', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
// 本地侧: relPath 带 ../ -> 直接判非法路径, 一步都不该走
const r = await applySyncItems({ ...ctx, items: [{ relPath: '../evil-escape.txt', action: 'upload' }] })
assert(!r.ok, '本地穿越竟然成功了!')
assert(!existsSync(join(dirname(A.syncRoot), 'evil-escape.txt')), '文件写到同步目录外面了!')
// 远端侧: 绕过客户端, 直接打对方的 /sync/file
const q = `token=${encodeURIComponent(ctx.token!)}&peerId=${encodeURIComponent(A.info.deviceId)}&relationId=${encodeURIComponent(relAB)}`
const body = Buffer.from('EVIL')
const up = await httpJson(B.info.filePort, `/sync/file?${q}`, {
method: 'POST',
headers: {
'x-file-rel-path': encodeURIComponent('../evil-escape.txt'),
'x-file-size': String(body.length),
'x-file-overwrite': '1',
'Content-Length': String(body.length),
},
body,
})
assert(up.status !== 200, `远端上传穿越竟然成功了 (HTTP ${up.status})`)
assert(!existsSync(join(dirname(B.syncRoot), 'evil-escape.txt')), '远端文件写到同步目录外面了!')
// 下载方向也要挡住
const dl = await httpJson(B.info.filePort, `/sync/file?${q}&path=${encodeURIComponent('../evil-escape.txt')}`)
assert(dl.status !== 200, `远端下载穿越竟然成功了 (HTTP ${dl.status})`)
})
await runCase('M10 幂等: 同一批变更连续 apply 两次, 结果不变, 不产生重复副作用', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
writeFileSync(join(A.syncRoot, 'idem.txt'), 'IDEMPOTENT')
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'idem.txt')
assert(!!it, 'idem.txt 没出现在扫描结果里')
assert(it!.action === 'upload', `新文件应判 upload, 实际 ${it!.action}`)
const clockBefore = A.manifestStore.loadManifest(relAB).clock
const r1 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'upload' }] })
assert(r1.ok, `第一次同步失败: ${JSON.stringify(r1.results)}`)
const r2 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'upload' }] })
assert(r2.ok, `第二次同步失败: ${JSON.stringify(r2.results)}`)
// 结果不变: 内容一样, 没有多出副本, 关系表时钟也没动
assert(readText(join(B.syncRoot, 'idem.txt')) === 'IDEMPOTENT', '对方内容不对')
const copies = readdirSync(B.syncRoot).filter(n => n.includes('idem'))
assert(copies.length === 1, `对方多出了副本: ${copies.join(',')}`)
const clockAfter = A.manifestStore.loadManifest(relAB).clock
assert(clockAfter === clockBefore, `重复 apply 让关系表时钟前进了: ${clockBefore} -> ${clockAfter}`)
// 再扫一次: 应该是 same, 不该再上传
const it2 = (await scanSync(ctx)).items.find(i => i.relPath === 'idem.txt')
assert(!!it2 && it2.action === 'same', `重复 apply 后还在上传: ${it2?.action}`)
// 删除也一样幂等
rmSync(join(A.syncRoot, 'idem.txt'), { force: true })
const it3 = (await scanSync(ctx)).items.find(i => i.relPath === 'idem.txt')
assert(!!it3 && it3.action === 'delete-remote', `本地删了应判 delete-remote, 实际 ${it3?.action}`)
const d1 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'delete-remote' }] })
assert(d1.ok, `第一次删除失败: ${JSON.stringify(d1.results)}`)
const d2 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'delete-remote' }] })
assert(d2.ok, `重复删除失败: ${JSON.stringify(d2.results)}`)
assert(!existsSync(join(B.syncRoot, 'idem.txt')), '对方那份还在')
})
}
// ============ 文件夹同步 ============
if (want('sync')) {
console.log('\n\x1b[1m[文件夹同步]\x1b[0m')
// 同步区是**独立于网盘**的一套空间 (/sync/*): 要有关系里的 sync 授权
const Y1 = new Node('peer-y1111111-0000-4000-8000-0000000000y1', '同步端Y1')
const Y2 = new Node('peer-y2222222-0000-4000-8000-0000000000y2', '同步端Y2')
await Y1.start()
await Y2.start()
await link(Y1, Y2)
// 前置: 建一条同步关系 (同步区走 /sync/*, 没关系连不上)
// 注意顺序: **先建关系再放文件** —— 实际同步根 = 顶层目录 + 关系子目录,
// 关系没建好时子目录还没分配, 文件放错地方就"凭空消失"了。
let yRelId = ''
let ySetupErr = ''
try {
yRelId = (await establishRelation(Y1, Y2)).relationId
} catch (e: any) {
ySetupErr = String(e?.message || e)
}
mkdirSync(join(Y1.syncRoot, 'sub'), { recursive: true })
writeFileSync(join(Y1.syncRoot, 'same.txt'), 'SAME-CONTENT')
writeFileSync(join(Y1.syncRoot, 'sub', 'deep.txt'), 'DEEP-CONTENT')
writeFileSync(join(Y2.syncRoot, 'only-remote.txt'), 'REMOTE-ONLY')
const yCtx = (): SyncContext => {
assert(!!yRelId, `前置条件不成立: 同步关系没建起来 (${ySetupErr})`)
return syncCtx(Y1, Y2, yRelId)
}
await runCase('Y1 本地扫描: 相对路径 / 大小 / hash 正确', async () => {
const r = scanLocalDir(Y1.syncRoot)
assert(r.ok, `扫描失败: ${r.reason}`)
const names = r.files.map(f => f.relPath)
assert(names.includes('same.txt'), `缺 same.txt: ${names.join(',')}`)
assert(names.includes('sub/deep.txt'), `缺深层文件: ${names.join(',')}`)
const f = r.files.find(x => x.relPath === 'same.txt')!
assert(f.size === 'SAME-CONTENT'.length, `大小不对: ${f.size}`)
assert(!!f.hash && f.hash.length === 64, `hash 不对: ${f.hash}`)
})
await runCase('Y2 三方对比: 只有一边有 / 内容一致 / 都不对', async () => {
const local = scanLocalDir(Y1.syncRoot).files
const same = local.find(f => f.relPath === 'same.txt')!
const man: Manifest = { relationId: 'y-unit', clock: 0, remoteSeq: 0, entries: {} }
reconcileLocal(man, local) // 先把本地现状对齐进关系表 (跟 scanSync 里做的一样)
const peer = new Map<string, ManifestWireEntry>([
['same.txt', { fileId: 'p1', relPath: 'same.txt', hash: same.hash!, size: same.size, mtime: same.mtime, state: 'present', seq: 1, deletedAt: 0 }],
['only-remote.txt', { fileId: 'p2', relPath: 'only-remote.txt', hash: 'b'.repeat(64), size: 10, mtime: Date.now(), state: 'present', seq: 1, deletedAt: 0 }],
])
const { items, stats } = planSync(man, local, peer)
const get = (p: string) => items.find(i => i.relPath === p)!
assert(get('same.txt').action === 'same', `same.txt 判成了 ${get('same.txt').action}`)
assert(get('only-remote.txt').action === 'download', `only-remote 判成了 ${get('only-remote.txt').action}`)
assert(get('sub/deep.txt').action === 'upload', `deep.txt 判成了 ${get('sub/deep.txt').action}`)
assert(stats.same === 1 && stats.download === 1 && stats.upload === 1,
`统计不对: ${JSON.stringify(stats)}`)
})
await runCase('Y3 首次同步没有基线: 内容不同且修改时间接近 -> 判 conflict (不静默覆盖)', async () => {
const now = Date.now()
const local: SyncFileInfo[] = [{ relPath: 'c.txt', size: 3, mtime: now, hash: 'a'.repeat(64) }]
const man: Manifest = { relationId: 'y-unit3', clock: 0, remoteSeq: 0, entries: {} }
reconcileLocal(man, local)
const peer = new Map<string, ManifestWireEntry>([['c.txt', {
fileId: 'p', relPath: 'c.txt', hash: 'b'.repeat(64), size: 4, mtime: now + 500,
state: 'present', seq: 1, deletedAt: 0,
}]])
const { items, stats } = planSync(man, local, peer)
assert(items[0].action === 'conflict', `判成了 ${items[0].action}`)
assert(stats.conflict === 1, `冲突数不对: ${stats.conflict}`)
})
await runCase('Y4 首次同步没有基线: 谁更新就往谁的方向同步 (mtime 兜底)', async () => {
const now = Date.now()
const localNew: SyncFileInfo[] = [{ relPath: 'x.txt', size: 3, mtime: now + 60_000, hash: 'a'.repeat(64) }]
const remoteOld: SyncFileInfo[] = [{ relPath: 'x.txt', size: 4, mtime: now, hash: 'b'.repeat(64) }]
const mk = (files: SyncFileInfo[]): Manifest => {
const man: Manifest = { relationId: 'y-unit4', clock: 0, remoteSeq: 0, entries: {} }
reconcileLocal(man, files)
return man
}
const peerOld = new Map<string, ManifestWireEntry>([['x.txt', {
fileId: 'p', relPath: 'x.txt', hash: remoteOld[0].hash!, size: 4, mtime: remoteOld[0].mtime,
state: 'present', seq: 1, deletedAt: 0,
}]])
const peerNew = new Map<string, ManifestWireEntry>([['x.txt', {
fileId: 'p', relPath: 'x.txt', hash: localNew[0].hash!, size: 3, mtime: localNew[0].mtime,
state: 'present', seq: 1, deletedAt: 0,
}]])
assert(planSync(mk(localNew), localNew, peerOld).items[0].action === 'upload', '本地新应判 upload')
assert(planSync(mk(remoteOld), remoteOld, peerNew).items[0].action === 'download', '对方新应判 download')
})
await runCase('Y5 端到端: 上传的文件真的落到对方的同步区, 内容一致', async () => {
const ctx = yCtx()
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'sub/deep.txt', action: 'upload' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
const landed = join(Y2.syncRoot, 'sub', 'deep.txt')
assert(existsSync(landed), `文件没落到对方的同步区: ${landed}`)
assert(readText(landed) === 'DEEP-CONTENT', '内容不对')
})
await runCase('Y6 端到端: 下载对方的文件到本地', async () => {
const ctx = yCtx()
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'only-remote.txt', action: 'download' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
const landed = join(Y1.syncRoot, 'only-remote.txt')
assert(existsSync(landed), `文件没下载到本地: ${landed}`)
assert(readText(landed) === 'REMOTE-ONLY', '内容不对')
})
await runCase('Y7 建立基线后本地改了文件: 判 upload (不是 conflict) -> 同步后对方也更新', async () => {
const ctx = yCtx()
// 先建立基线: 扫一次 + 同步一次 (双方都认下这个文件)
const base = await scanSync(ctx)
assert(base.ok, `扫描失败: ${base.reason}`)
const baseItems = base.items
.filter(i => i.action === 'upload' || i.action === 'download')
.map(i => ({ relPath: i.relPath, action: i.action as 'upload' | 'download' }))
const rb = await applySyncItems({ ...ctx, items: baseItems })
assert(rb.ok, `建立基线失败: ${JSON.stringify(rb.results)}`)
// 基线建立后再扫一次应该是 same
const again = await scanSync(ctx)
const sameIt = again.items.find(i => i.relPath === 'same.txt')
assert(!!sameIt && sameIt.action === 'same', `建立基线后应该判 same, 实际 ${sameIt?.action}`)
// 现在改本地文件 -> 只有本地改过 -> upload
writeFileSync(join(Y1.syncRoot, 'same.txt'), 'LOCAL-EDITED-V2')
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'same.txt')
assert(!!it, 'same.txt 没出现在扫描结果里')
assert(it!.action === 'upload', `本地改过应判 upload, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'same.txt', action: 'upload' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(readText(join(Y2.syncRoot, 'same.txt')) === 'LOCAL-EDITED-V2', '对方同步区没被更新')
})
await runCase('Y8 目录穿越: 上传时 rel-path 带 ../ 必须被拒绝', async () => {
const ctx = yCtx()
assert(!existsSync(join(dirname(Y2.syncRoot), 'evil-escape.txt')), '测试前不该有同名文件')
const r = await applySyncItems({ ...ctx, items: [{ relPath: '../evil-escape.txt', action: 'upload' }] })
assert(!r.ok, '穿越竟然成功了!')
assert(!existsSync(join(dirname(Y2.syncRoot), 'evil-escape.txt')), '文件写到同步目录外面了!')
})
await runCase('Y9 token 不对 -> 对方拒绝 (403)', async () => {
const bad = { ...Y2.info, driveToken: 'wrong-token-sync' }
const scan = await scanSync({
peer: bad, localDir: Y1.syncLocalDir, remotePath: '',
relationId: yRelId, store: Y1.manifestStore,
})
assert(!scan.ok, '错 token 竟然扫成功了!')
assert(/令牌/.test(scan.reason || ''), `错误信息不对: ${scan.reason}`)
})
await runCase('Y10 扫描对方目录: 拿到递归清单和 hash', async () => {
const ctx = yCtx()
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const names = scan.items.map(i => i.relPath)
assert(names.includes('same.txt'), `缺 same.txt: ${names.join(',')}`)
assert(names.includes('sub/deep.txt'), `缺深层文件: ${names.join(',')}`)
const same = scan.items.find(i => i.relPath === 'same.txt')!
assert(same.remote && !!same.remote.hash, '远端没有 hash')
assert(same.local && !!same.local.hash, '本地没有 hash')
})
await Y1.stop()
await Y2.stop()
}
// ============ 审计 ============
if (want('audit')) {
console.log('\n\x1b[1m[审计]\x1b[0m')
await runCase('A1 关键操作都写进了 audit_log', async () => {
const rows = listAudit({ limit: 200 })
const actions = new Set(rows.map(r => r.action))
const need = ['terminal.open', 'forward.open']
for (const n of need) assert(actions.has(n), `audit 里没有 ${n} (现有: ${[...actions].join(', ')})`)
})
}
// ============ 图片 (直显 + 右键复制) ============
if (want('image')) {
console.log('\n\x1b[1m[图片]\x1b[0m')
const imgDir = join(tmpdir(), 'lnm-selftest-img')
rmSync(imgDir, { recursive: true, force: true })
mkdirSync(imgDir, { recursive: true })
// 造几个"图片"文件 (只写真头, 桩按魔数识别)
const writeFakeImage = (name: string, bytes: number, kind: 'png' | 'jpeg' = 'png') => {
const p = join(imgDir, name)
const head = kind === 'png'
? Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
: Buffer.from([0xff, 0xd8, 0xff, 0xe0])
writeFileSync(p, Buffer.concat([head, Buffer.alloc(Math.max(0, bytes - head.length), 0x41)]))
return p
}
const smallPng = writeFakeImage('small.png', 10_000)
const midPng = writeFakeImage('mid.png', 60_000)
const bigPng = writeFakeImage('big.png', 400_000)
const jpegFile = writeFakeImage('photo.jpg', 60_000, 'jpeg')
const notImage = join(imgDir, 'note.txt')
writeFileSync(notImage, 'just text')
await runCase('I1 纯逻辑: mime 识别 / 缩略图宽度只缩不放 / PNG 优先否则 JPEG', async () => {
assert(isImageMime('image/png'), 'image/png 应该认')
assert(isImageMime('IMAGE/JPEG'), '大小写不敏感')
assert(!isImageMime('application/octet-stream'), 'octet-stream 不该认')
assert(!isImageMime(''), '空 mime 不该认')
assert(pickThumbWidth(1200) === THUMB_MAX_WIDTH, `1200 应该缩到 ${THUMB_MAX_WIDTH}`)
assert(pickThumbWidth(200) === 200, '200 比上限小就不该放大')
assert(pickThumbWidth(0) === THUMB_MAX_WIDTH, '异常宽度退回上限')
assert(chooseThumbEncoding(1000, 5000) === 'png', 'PNG 够小就该用 PNG (保透明)')
assert(chooseThumbEncoding(100_000, 5000) === 'jpeg', 'PNG 超限就退 JPEG')
assert(chooseThumbEncoding(100_000, 100_000) === null, '两种都超限 -> null (调用方再缩)')
})
await runCase('I2 选图源: 自己发的用 localPath, 对方发的用 savedPath (发送方 DB 里的 savedPath 是对方的路径, 不能用)', async () => {
const body = { localPath: '/me/a.png', savedPath: '/peer/b.png', mime: 'image/png' }
const mine = pickImageSource(body, true)!
assert(mine.kind === 'file' && mine.path === '/me/a.png', `自己发的应选 localPath, 实际 ${mine.path}`)
const theirs = pickImageSource(body, false)!
assert(theirs.kind === 'file' && theirs.path === '/peer/b.png', `对方发的应选 savedPath, 实际 ${theirs.path}`)
// 没有本地文件时, 退回内嵌的 dataBase64 (粘贴的图)
const pasted = pickImageSource({ dataBase64: 'AAAA', mime: 'image/png' }, true)!
assert(pasted.kind === 'dataUrl' && pasted.dataUrl!.startsWith('data:image/png;base64,'), '应退回 data URL')
// 什么都没有 -> null
assert(pickImageSource({ name: 'x.png' }, true) === null, '没源应该返回 null')
})
await runCase('I3 缩略图: 小图走 PNG (保透明), 中图走 JPEG, 超大图降级重缩', async () => {
const small = makeThumbFromFile(smallPng)!
assert(small.startsWith('data:image/png;base64,'), `小图应该是 PNG, 实际 ${small.slice(0, 24)}`)
const mid = makeThumbFromFile(midPng)!
assert(mid.startsWith('data:image/jpeg;base64,'), `中图应该退 JPEG, 实际 ${mid.slice(0, 24)}`)
// 超大图: PNG/JPEG 都超限 -> 降级分支也要给得出结果
const big = makeThumbFromFile(bigPng)!
assert(big.startsWith('data:image/'), '超大图也该给出缩略图 (降级重缩)')
// JPEG 源文件
const jpg = makeThumbFromFile(jpegFile)!
assert(jpg.startsWith('data:image/'), 'JPEG 源也要能出缩略图')
})
await runCase('I4 缩略图: 非图片 / 不存在的文件 -> undefined (优雅降级成文件卡片, 不报错)', async () => {
assert(makeThumbFromFile(notImage) === undefined, 'txt 不该出缩略图')
assert(makeThumbFromFile(join(imgDir, 'nope.png')) === undefined, '不存在的文件不该出缩略图')
assert(makeThumbFromFile('') === undefined, '空路径不该出缩略图')
// 目录也不行
assert(makeThumbFromFile(imgDir) === undefined, '目录不该出缩略图')
})
await runCase('I5 缩略图缓存: 同一张图生成两次 -> 结果一致 (不重复解码)', async () => {
__clearThumbCache()
const a = makeThumbFromFile(midPng)
const b = makeThumbFromFile(midPng)
assert(a === b && !!a, '同一文件应命中缓存')
})
await runCase('I6 右键复制图片: 写的是原图 (本地文件), 不是缩略图', async () => {
const r = copyToClipboard({ kind: 'file', path: smallPng, mime: 'image/png' })
assert(r.ok, `复制应成功: ${r.reason || ''}`)
const written = electronStub.__clipboardState.image
assert(!!written, '剪贴板里应有图片')
const size = written.getSize()
assert(size.width === 1200 && size.height === 900, `应写原图尺寸, 实际 ${size.width}x${size.height}`)
})
await runCase('I7 右键复制图片: 文件没了/解不了 -> 明确失败, 不静默', async () => {
const r = copyToClipboard({ kind: 'file', path: join(imgDir, 'gone.png'), mime: 'image/png' })
assert(!r.ok, '文件不存在应失败')
assert(!!r.reason, '失败要带原因')
const r2 = copyToClipboard(null)
assert(!r2.ok && !!r2.reason, '没有源应失败并带原因')
})
await runCase('I8 缩略图缓存按 mtime 失效: 改了文件 -> 缩略图跟着变', async () => {
__clearThumbCache()
const p = writeFakeImage('mutable.png', 10_000)
const first = makeThumbFromFile(p)!
writeFileSync(p, Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(30_000, 0x42)]))
const second = makeThumbFromFile(p)!
assert(first !== second, '文件改了, 缩略图不该还用缓存')
})
}
// ============ 未读/角标 ============
if (want('unread')) {
console.log('\n\x1b[1m[未读/角标]\x1b[0m')
await runCase('U1 正在看的会话: 不管有没有 savedPath 都要清未读 (文字消息没 savedPath, 以前就是被提前 return 掉的)', async () => {
// 文字消息: 没有 savedPath —— 这是以前漏清未读的那条路径
assert(unreadActionForIncoming({ isActive: true, hasSavedPath: false }) === 'clear',
'正在看 + 文字消息(无 savedPath) 也必须清未读')
// 文件消息第一帧 (文件在路上)
assert(unreadActionForIncoming({ isActive: true, hasSavedPath: false }) === 'clear',
'正在看时第一帧也要清')
// 文件消息第二帧 (落地了)
assert(unreadActionForIncoming({ isActive: true, hasSavedPath: true }) === 'clear',
'正在看 + 文件落地 -> 清')
})
await runCase('U2 不在当前会话: 文件没落地先等, 落地了才算未读', async () => {
assert(unreadActionForIncoming({ isActive: false, hasSavedPath: false }) === 'wait',
'不在当前会话 + 文件在路上 -> 等')
assert(unreadActionForIncoming({ isActive: false, hasSavedPath: true }) === 'count',
'不在当前会话 + 文件落地 -> 算未读')
})
await runCase('U3 未读计数: 加了能清掉, 清完总数归零', async () => {
const peer = 'peer-unread-0000-4000-8000-0000000000cc'
clearUnread(peer)
const base = totalUnread() // 别假设起点是 0 (别的用例可能留了未读)
incrUnread(peer)
assert(totalUnread() === base + 1, `加了未读后总数应 +1, 实际 ${totalUnread() - base}`)
assert((getUnread()[peer] ?? 0) === 1, `该设备的未读数应为 1, 实际 ${getUnread()[peer]}`)
clearUnread(peer)
assert(totalUnread() === base, `清掉后总数应回到 ${base}, 实际 ${totalUnread()}`)
assert((getUnread()[peer] ?? 0) === 0, `该设备的未读数应为 0, 实际 ${getUnread()[peer]}`)
clearUnread(peer)
assert(totalUnread() === base, '重复清也不该出问题')
})
await runCase('U4 未读计数: 多个设备累加, 清一个不影响另一个', async () => {
const p1 = 'peer-unread-0000-4000-8000-0000000000d1'
const p2 = 'peer-unread-0000-4000-8000-0000000000d2'
clearUnread(p1); clearUnread(p2)
const base = totalUnread()
incrUnread(p1); incrUnread(p1); incrUnread(p2)
assert(totalUnread() === base + 3, `三个未读, 实际多了 ${totalUnread() - base}`)
clearUnread(p1)
assert(totalUnread() === base + 1, `清掉一个后应剩 1, 实际多了 ${totalUnread() - base}`)
assert((getUnread()[p2] ?? 0) === 1, `另一个设备的未读不该被动到, 实际 ${getUnread()[p2]}`)
clearUnread(p2)
assert(totalUnread() === base, '全清完应回到基线')
})
}
// ============ 汇总 ============
await A.stop()
await B.stop()
await E.stop()
await F.stop()
await G.stop()
const pass = results.filter(r => r.ok).length
const fail = results.length - pass
console.log(`\n\x1b[1m结果: ${pass} 通过 / ${fail} 失败\x1b[0m`)
if (fail) {
console.log('\n失败用例:')
for (const r of results.filter(x => !x.ok)) console.log(` - ${r.name}\n ${r.detail}`)
}
process.exit(fail ? 1 : 0)
}
main().catch((e) => {
console.error('自测脚本异常:', e)
process.exit(2)
})