Files
mihomo-patchbay/server/config-store.test.ts
T
im0o f761d13579 反馈改右上角轻提示;侧栏加撤回重做等配置动作
设置页的横幅换成 toast(右滑入、几秒后上移淡出,web/toast.ts 单例):
动作结果看一眼就行,不值得在页面里占一块常驻位置。表单校验类的错误
仍留在各自表单里。

撤回栈就是备份链:撤回把最新备份应用回去并消费掉它,撤回前的状态进
重做栈(.patchbay-redo,同样留 10 份);重做反向;新的正常保存清空
重做栈,不留分叉历史。这两条路复用 applyConfig 的保护链路,用
restoreFrom 参数跳过自建备份——否则备份链会被撤回本身污染,撤两次
等于原地打转。侧栏左下角一排图标:撤回、重做、历史记录(备份列表
弹窗)、更新配置(POST /api/kernel/reload,给 SSH 手改后用)、重启
内核;收拢时容器变窄自动折成一列。

设置页顺手修三处:GEO 行的编辑按钮在输入框开着时再点会闪(blur 关了
click 又开),mousedown.prevent 保焦点、click 显式开关;上传按钮改
「选择或拖放文件」,投放区拖入高亮;恢复策略挪到恢复行下面。
2026-08-12 01:59:22 +08:00

256 lines
9.2 KiB
TypeScript

import { afterAll, describe, expect, it } from 'bun:test'
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
applyConfig,
importConfig,
listBackups,
listRedo,
readConfig,
redoConfig,
restoreBackup,
undoConfig,
} from './config-store.ts'
import type { Env } from './env.ts'
/**
* 落盘保护链路的测试,跑在一个假内核上:/version 报在线,PUT /configs 默认放行。
* mihomo -t 那条离线路径这里测不了(需要真的内核二进制),离线行为只能靠人工验证。
*/
let rejectNextReload = false
let reloadCount = 0
const kernel = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/version') return Response.json({ version: 'test' })
if (url.pathname === '/configs' && req.method === 'PUT') {
reloadCount += 1
if (rejectNextReload) {
rejectNextReload = false
return new Response('parse error: something wrong', { status: 400 })
}
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
},
})
afterAll(() => kernel.stop())
const ORIGINAL = 'mixed-port: 7890\n'
function makeEnv(): Env {
const dir = mkdtempSync(join(tmpdir(), 'patchbay-store-'))
const configPath = join(dir, 'config.yaml')
writeFileSync(configPath, ORIGINAL, 'utf8')
return {
mihomoApi: `http://127.0.0.1:${String(kernel.port)}`,
mihomoSecret: 'x',
configPath,
mihomoBin: join(dir, '不存在的内核'),
mihomoHome: dir,
host: '127.0.0.1',
port: 0,
}
}
describe('落盘保护链路(内核在线)', () => {
it('正常保存:写盘、留一份改动前的备份、通知重载', async () => {
const env = makeEnv()
const { data, revision } = await readConfig(env)
const before = reloadCount
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
expect(result.ok).toBe(true)
if (result.ok) expect(result.reloaded).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
expect(reloadCount).toBe(before + 1)
const backups = await listBackups(env)
expect(backups).toHaveLength(1)
const backupBody = readFileSync(
join(env.configPath, '..', '.patchbay-backups', backups[0]!),
'utf8',
)
expect(backupBody).toBe(ORIGINAL)
})
it('基准过时被拦住,文件一个字节不动', async () => {
const env = makeEnv()
const { data } = await readConfig(env)
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, '基准已过时')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.stage).toBe('stale')
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
expect(await listBackups(env)).toHaveLength(0)
})
it('内容没变直接返回,不备份也不重载', async () => {
const env = makeEnv()
// 先落一次盘让文件带上 Patchbay 标记——没有标记的文件第一次保存必有
// 「补标记」这一处内容变化,短路只发生在稳态
const first = await readConfig(env)
await applyConfig(env, first.data, first.revision)
const { data, revision } = await readConfig(env)
const before = reloadCount
const backupsBefore = (await listBackups(env)).length
const result = await applyConfig(env, data, revision)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.reloaded).toBe(false)
expect(result.reloadMessage).toBe('内容没有变化')
}
expect(reloadCount).toBe(before)
expect((await listBackups(env)).length).toBe(backupsBefore)
})
it('自检拦下断引用:入站的 proxy 指向不存在的出站', async () => {
// mihomo -t 和重载都不检查这种引用(实测 v1.19.29),只有这层自检拦得住
const env = makeEnv()
const { data, revision } = await readConfig(env)
const next = {
...data,
listeners: [{ name: 'x', type: 'mixed', port: 1080, proxy: '不存在的组' }],
}
const result = await applyConfig(env, next, revision)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.stage).toBe('self-check')
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
})
it('内核拒绝重载时把文件还原,并让内核载回原样', async () => {
const env = makeEnv()
const { data, revision } = await readConfig(env)
rejectNextReload = true
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.stage).toBe('reload')
expect(result.restored).toBe(true)
expect(result.message).toContain('parse error')
}
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
})
it('恢复备份走同一条保护链路', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
const [backup] = await listBackups(env)
const result = await restoreBackup(env, backup!)
expect(result.ok).toBe(true)
// 还原也走落盘链路,所以会重新打上 Patchbay 标记——内容回去了,但不逐字节等于旧文件
const body = readFileSync(env.configPath, 'utf8')
expect(body).toContain('mixed-port: 7890')
expect(body).not.toContain('7891')
expect(body).toContain('patchbay:')
})
it('覆盖导入整份替换,兼容导入只搬接管段落', async () => {
const env = makeEnv()
const uploaded = 'mixed-port: 9999\nrules:\n - MATCH,DIRECT\n'
const compat = await importConfig(env, uploaded, 'compatible')
expect(compat.ok).toBe(true)
let body = readFileSync(env.configPath, 'utf8')
// rules 是接管段落被搬过来,mixed-port 属于机器底座维持当前值
expect(body).toContain('MATCH,DIRECT')
expect(body).toContain('mixed-port: 7890')
expect(body).not.toContain('9999')
const over = await importConfig(env, uploaded, 'override')
expect(over.ok).toBe(true)
body = readFileSync(env.configPath, 'utf8')
expect(body).toContain('mixed-port: 9999')
})
it('导入解析不过的文件时一个字节都不动', async () => {
const env = makeEnv()
const result = await importConfig(env, '{{{ 不是 yaml', 'override')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.stage).toBe('self-check')
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
})
it('恢复只认列表里的名字,路径穿越进不来', async () => {
const env = makeEnv()
const result = await restoreBackup(env, '../config.yaml')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.message).toContain('没有这个备份')
})
it('撤回回到改动前,重做拿回改动,备份链不被撤回污染', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
const undone = await undoConfig(env)
expect(undone.ok).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
// 备份被消费、当前状态进了重做栈
expect(await listBackups(env)).toHaveLength(0)
expect(await listRedo(env)).toHaveLength(1)
const redone = await redoConfig(env)
expect(redone.ok).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
expect(await listRedo(env)).toHaveLength(0)
expect(await listBackups(env)).toHaveLength(1)
})
it('栈空时撤回和重做被拦住,文件不动', async () => {
const env = makeEnv()
const undone = await undoConfig(env)
expect(undone.ok).toBe(false)
if (!undone.ok) expect(undone.message).toContain('没有可以撤回')
const redone = await redoConfig(env)
expect(redone.ok).toBe(false)
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
})
it('新的保存清空重做栈', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
await undoConfig(env)
expect(await listRedo(env)).toHaveLength(1)
const { data, revision } = await readConfig(env)
await applyConfig(env, { ...data, 'mixed-port': 7892 }, revision)
expect(await listRedo(env)).toHaveLength(0)
})
it('备份最多留 10 份,旧的被清掉', async () => {
const env = makeEnv()
const dir = join(env.configPath, '..', '.patchbay-backups')
mkdirSync(dir, { recursive: true })
for (let i = 10; i < 22; i += 1) {
writeFileSync(join(dir, `config.yaml.bak-20200101-0000${String(i)}`), 'old', 'utf8')
}
const { data, revision } = await readConfig(env)
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
const backups = await listBackups(env)
expect(backups).toHaveLength(10)
// 名字按时间戳排序,留下的是最新的:刚生成的那份在最前,2020 年的只剩最近的九份
expect(backups[0]!.startsWith('config.yaml.bak-2020')).toBe(false)
expect(backups).not.toContain('config.yaml.bak-20200101-000010')
})
})