// 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) { 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 { 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 { 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(fn: () => T | undefined | null | false, timeoutMs = 5000, stepMs = 30): Promise { 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; 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((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 | 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"gi|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// 里的 .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((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([['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([['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([ ['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([['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([['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([['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) })