备份存的是「改动前」,所以一条记录的 diff 两端是它自己和更新一份的 状态(最新一条对比当前文件)。行级 diff 先掐公共前后缀再做 LCS,一次 改动通常只碰几行,表撑得住;大面积不同时退化成整段删增。 合并窗做在服务端而不是前端防抖:普通保存(opts.merge)发现最新备份 还在窗内就不新建,连点删除共享一条历史、一步撤回。窗锚定在这一串的 第一下,不滚雪球;还原、导入、撤回重做不合并,各占一条。合并时没有 备份文件,重载失败的还原改用刚读出的原文写回。顺带消除了备份名只精 确到秒、同秒保存互相覆盖的隐患。
337 lines
12 KiB
TypeScript
337 lines
12 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,
|
||
backupDiff,
|
||
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')
|
||
})
|
||
})
|
||
|
||
describe('连续保存的合并窗(opts.merge)', () => {
|
||
const stampName = (at: Date) => {
|
||
const p = (n: number) => String(n).padStart(2, '0')
|
||
return (
|
||
`config.yaml.bak-${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
||
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
|
||
)
|
||
}
|
||
|
||
it('窗内的第二次保存不新建备份,一步撤回回到这一串之前', async () => {
|
||
const env = makeEnv()
|
||
const first = await readConfig(env)
|
||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
|
||
const second = await readConfig(env)
|
||
await applyConfig(env, { ...second.data, 'mixed-port': 7892 }, second.revision, { merge: true })
|
||
|
||
expect(await listBackups(env)).toHaveLength(1)
|
||
|
||
const undone = await undoConfig(env)
|
||
expect(undone.ok).toBe(true)
|
||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||
})
|
||
|
||
it('最新备份出了窗就正常新建', async () => {
|
||
const env = makeEnv()
|
||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||
mkdirSync(dir, { recursive: true })
|
||
writeFileSync(join(dir, stampName(new Date(Date.now() - 60_000))), 'old', 'utf8')
|
||
|
||
const { data, revision } = await readConfig(env)
|
||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision, { merge: true })
|
||
|
||
expect(await listBackups(env)).toHaveLength(2)
|
||
})
|
||
|
||
it('不带 merge 的保存(还原、导入)不合并,窗内照样各留一条', async () => {
|
||
const env = makeEnv()
|
||
const first = await readConfig(env)
|
||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
|
||
const [backup] = await listBackups(env)
|
||
|
||
const result = await restoreBackup(env, backup!)
|
||
expect(result.ok).toBe(true)
|
||
// 还原自己也备份了「还原前」的状态。名字按秒生成,和上一份同秒时会覆盖而不是
|
||
// 新增,所以这里断言的是「至少还有一份、且文件回到了原样」
|
||
expect((await listBackups(env)).length).toBeGreaterThanOrEqual(1)
|
||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||
})
|
||
})
|
||
|
||
describe('历史记录的对比原文', () => {
|
||
it('最新一条对比当前文件,改动前后各归各', async () => {
|
||
const env = makeEnv()
|
||
const { data, revision } = await readConfig(env)
|
||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||
|
||
const [newest] = await listBackups(env)
|
||
const pair = await backupDiff(env, newest!)
|
||
expect(pair).not.toBeNull()
|
||
expect(pair!.before).toBe(ORIGINAL)
|
||
expect(pair!.after).toContain('mixed-port: 7891')
|
||
})
|
||
|
||
it('更老的一条对比它前面那份备份', async () => {
|
||
const env = makeEnv()
|
||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||
mkdirSync(dir, { recursive: true })
|
||
writeFileSync(join(dir, 'config.yaml.bak-20200101-000001'), '第一份\n', 'utf8')
|
||
writeFileSync(join(dir, 'config.yaml.bak-20200101-000002'), '第二份\n', 'utf8')
|
||
|
||
const pair = await backupDiff(env, 'config.yaml.bak-20200101-000001')
|
||
expect(pair).toEqual({ before: '第一份\n', after: '第二份\n' })
|
||
})
|
||
|
||
it('名字不在备份列表里返回 null', async () => {
|
||
const env = makeEnv()
|
||
expect(await backupDiff(env, '../../etc/passwd')).toBeNull()
|
||
})
|
||
})
|