Files
LocalNetMsg/scripts/selftest/main.ts
T
xiaoshuai 2b63bf7d0b feat(image): 图片直接显示 + 右键复制图片; 修未读角标不清
## 图片直显 + 右键复制

以前图片消息基本只能看到 "[图片] xxx.png" 的文件卡片, 而且右键没有"复制"。

根因有两层:

1. `asImage` 从来没被设成 true —— 用"发送文件"选中的图片是当**普通文件**发的,
   对方只能看到文件卡片。
2. 拖拽/粘贴的图走 `sendBuffer`, 只在 <200KB 时才带 `thumbDataUrl`,
   大图没有缩略图 -> 渲染层 fallback 成文件卡片。

改法:

- 缩略图随消息体走 (`body.thumbDataUrl`): **发送方生成一次, 对方直接拿来显示**,
  对端不用再解码, 老版本对端也看得到图。
- 缩略图用 Electron 的 `nativeImage` 生成 (`src/main/image.ts`), 不引 sharp/jimp:
  宽度上限 480, **PNG 优先 (保透明 —— 截图转 JPEG 会变黑底)**, PNG 超 60KB 才退 JPEG,
  然后按"降质量 -> 缩尺寸"的阶梯找一档够小的。
  阈值/编码选择/选图源抽到 `src/main/image-core.ts` (纯逻辑, 不 import electron, 可单测)。
  源文件 >20MB 不生成 —— nativeImage 解码会把整张图铺成 RGBA 内存。
- 拖拽/选中的图片改走 `sendFile(asImage: true)` (有真实路径, 流式上传),
  只有粘贴的图片才走 `sendBuffer` (整图 base64 进 WS 帧, 大图撑爆带宽)。
- 右键"复制图片" -> `image:copy` IPC -> `clipboard.writeImage`, 写的是**原图**
  (本地文件优先, 退回 dataBase64), 不是气泡里那个缩略图。
- 缩略图缺失时 (老消息/老版本对端) 渲染层退到 `image:thumb` 现场生成,
  失败才显示文件卡片。点开看大图用 `image:dataUrl` 读本地原图。

## 修未读角标不清

用户正看着某个会话, dock/任务栏角标的数字却不消。

根因: `App.vue` 的 `message:received` 里先判 `if (!savedPath) return` (等文件落地再算未读),
而**文字消息没有 savedPath** —— 正好在"清未读"之前就 return 了。
文件/图片消息因为第二帧带 savedPath 才碰巧能清掉, 所以只有文字消息暴露这个 bug。

改法:

- 决策抽成纯函数 `src/renderer/src/utils/unread.ts` 的 `unreadActionForIncoming()`
  (`clear` / `wait` / `count`): **先判 isActive -> 清未读 + 回已读回执, 再判 savedPath**。
- 侧边栏**重复点同一个设备**时 activePeerId 不变, watch 不触发 -> 显式清一次。
- 主进程 `device:delete` 清了 unread 但忘了 `updateBadge()` -> 角标残留, 补上。

## 验证

- npm run typecheck        ✅
- npm run build            ✅
- npm run selftest         ✅ 76 通过 / 0 失败 (新增 I1-I8 图片 + U1-U4 角标)
- npm run selftest:sync    ✅ 全部通过
- npm run selftest:image   ✅ 真 Electron 下验 nativeImage (新增命令)
- npm run package:linux    ✅ 新 deb
- npm run selftest:package ✅ 4/4

真机 (CDP 驱动真实窗口) 验证过: 图片气泡内联显示 (640x420, fallback 0 个),
右键菜单含"复制图片", 点下去系统剪贴板里是 **640x420 原图** (不是缩略图)。
2026-10-21 14:52:37 +08:00

1708 lines
87 KiB
TypeScript

// LocalNetMsg 远程功能自测 (terminal / forward / usb)
// 在一个进程里跑两个虚拟节点 (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 { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb'
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, UsbFrame,
RelationRequestFrame, RelationAcceptFrame, RelationRejectFrame,
} 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
usb: UsbBridgeManager
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: 1,
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.usb = new UsbBridgeManager(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.usb.handleIncoming(peer, frame.payload as UsbFrame)
} 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.usb.on('output', (sessionId, bytes, peerId) =>
this.events.push({ kind: 'usb:output', sessionId, peerId, data: bytes.toString('base64') }))
this.usb.on('sessionOpened', (i: any) =>
this.events.push({ kind: 'usb:opened', sessionId: i.sessionId, peerId: i.peerId }))
this.usb.on('sessionClosed', (i: any) =>
this.events.push({ kind: 'usb:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason }))
this.usb.on('sessionError', (i: any) =>
this.events.push({ kind: 'usb: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.usb.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, usb: true },
[B.info.deviceId]: { terminal: true, forward: true, usb: 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()
}
}
// ============ USB / 串口 ============
console.log('\n\x1b[1m[USB / 串口]\x1b[0m')
if (want('usb')) {
await runCase('U1 列对方设备: 请求-响应链路通', async () => {
const r = await A.usb.listOnPeer(B.info.deviceId)
assert(r.ok, `list 失败: ${r.reason}`)
assert(Array.isArray(r.devices), 'devices 不是数组')
})
await runCase('U2 USB 模式设备枚举 (libusb) 能列出真机设备', async () => {
const devs = await B.usb.listLocal()
const usbDevs = devs.filter(d => d.kind === 'usb')
console.log(` (枚举到 ${devs.length} 个设备, 其中 usb ${usbDevs.length} 个)`)
assert(usbDevs.length > 0, '一个 libusb 设备都没枚举到 —— USB 模式不可用')
})
await runCase('U2b 内核占用的设备: attach 失败原因能正确回传到控制端', async () => {
const devs = await B.usb.listLocal()
const usbDevs = devs.filter(d => d.kind === 'usb')
assert(usbDevs.length > 0, '没有可用的 usb 设备')
// 这台机器上的设备都被内核驱动占着 (键鼠/摄像头/蓝牙), 测试里不让它抢驱动
const pick = usbDevs.find(d => /hub/i.test(d.product || '')) || usbDevs[0]
console.log(` (选用设备 ${pick.busId} ${pick.product || ''})`)
const r = await A.usb.attach(B.info.deviceId, {
busId: pick.busId, direction: 'self-out',
config: { kind: 'usb', configurationValue: 1, interfaceNumber: 0, detachKernelDriver: false },
})
// 期望: 要么成功, 要么失败原因能传回来 (而不是超时/静默)
if (!r.ok) {
console.log(` (按预期被内核占用挡住: ${r.reason})`)
assert(!!r.reason && r.reason.length > 0, '失败时没有给出原因')
}
})
await runCase('U2c 控制传输 setup 翻译正确, 真机 GET_DESCRIPTOR 能拿到数据', async () => {
const usbMod: any = require('usb')
const devs = await usbMod.webusb.loadDevices()
assert(devs.length > 0, 'libusb 枚举不到设备')
const dev = devs.find((d: any) => d.vendorId === 0x0c45 && d.productId === 0x6366) || devs[0]
await dev.open()
try {
// 用应用里同一套翻译函数 (bmRequestType 数值 -> node-usb v3 字符串枚举)
const setup = toUsbControlSetup({ requestType: 0x80, request: 0x06, value: 0x0100, index: 0x0000 })
const buf = await dev.nativeControlTransferIn(setup, 3000, 18)
assert(!!buf && buf.length === 18, `描述符长度不对: ${buf?.length}`)
const hexd = Buffer.from(buf).toString('hex')
console.log(` (descriptor: ${hexd})`)
assert(hexd.startsWith('1201'), '不是合法的设备描述符')
} finally {
await dev.close().catch(() => {})
}
})
// 串口双向字节流: 用 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.usb.listLocal = async () => ([{
busId: `serial:${pair.userPath}`, vid: 0, pid: 0, deviceClass: 0x02, deviceSubclass: 0x02,
product: 'selftest-pty', manufacturer: 'socat', kind: 'serial', serialPath: pair.userPath, baudRate: 115200,
}] as any)
await runCase('U3 attach 串口: 控制端能拿到 attached ok', async () => {
const r = await A.usb.attach(B.info.deviceId, {
busId: `serial:${pair.userPath}`, direction: 'self-out',
config: { kind: 'serial', baudRate: 115200 },
})
assert(r.ok, `attach 被拒: ${r.reason}`)
})
await runCase('U4 串口双向字节流: 控制端 -> 设备', async () => {
const sess = A.usb.listClientSessions()[0]
assert(!!sess, '控制端没有 usb session')
devEnd = openPty(pair.internalPath)
const ok = await A.usb.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('U5 串口双向字节流: 设备 -> 控制端', async () => {
assert(!!devEnd, '设备端没准备好')
A.clearEvents()
devEnd.write('HELLO-FROM-DEVICE\n')
await waitFor(() => A.findEvents('usb:output').map(e => decode(e.data!)).join('').includes('HELLO-FROM-DEVICE'), 6000)
.catch(() => { throw new Error('控制端没收到设备发来的字节') })
})
} finally {
try { devEnd?.close() } catch {}
try { socatProc?.kill() } catch {}
try { rmSync(vcomDir, { recursive: true, force: true }) } catch {}
}
}
// ============ 网盘 ============
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)
})