按审查结论重构:先补测试网,再搬代码

顺序是刻意的。前端一行测试都没有,直接挪代码挪坏了 bun test 不会红,所以先把纯逻辑
抽到能被测试的位置并补上用例,最后才动组件。测试从 37 条增加到 77 条。

抽出来的四块:
- shared/outbound-chain.ts  「这个端口现在走哪条线」的链路解析,带防环,9 条测试
- web/delay-test.ts          延迟测试失败时说什么,6 个分支原本一条测试都没有
- web/runtime.ts             运行时状态的模块级单例,照 prefs.ts 已有的模式
- web/components/InboundList.vue  入站列表

出站的文本原本散成四份(读一份、显示一份、写回一份、表单又抄一份),加一种出站模式
要同时改四处,漏一个就是显示与写回不一致。合并成 OUTBOUND_PRESETS 一张表。

给认证和透传白名单补了 20 条测试。两者都是安全边界、都是纯函数、都不需要内核在线,
之前却只靠需要活内核的冒烟脚本间接覆盖。其中「形似但不是健康检查的路径」几条确认了
正则没写松。

另外:配置快照不再把整份文件原文下发(前端从不读,白白让响应体积翻倍);
yaml-cosmetics 的测试改走生产路径,之前它自己拼了一遍三步流程,落盘链路漏掉其中一步
测试照样会绿。
This commit is contained in:
im0o
2026-08-10 18:21:23 +08:00
parent 6b208847a2
commit c5ac59ecf9
19 changed files with 781 additions and 355 deletions
+34
View File
@@ -6,7 +6,10 @@ import {
collectInbounds,
collectOutboundNames,
fallbackTarget,
isUnsetOutbound,
outboundLabel,
outboundToProxyField,
OUTBOUND_PRESETS,
resolveDestination,
} from './inbounds.ts'
@@ -145,3 +148,34 @@ describe('流量最终去哪', () => {
expect(fallbackTarget({})).toBeNull()
})
})
describe('出站预设表是读写显示三者的唯一约定', () => {
it('每种预设的字段值和标签一一对应', () => {
expect(OUTBOUND_PRESETS.map((p) => [p.mode.kind, p.field, p.label])).toEqual([
['rules', undefined, 'RULE'],
['direct', 'DIRECT', 'DIRECT'],
['global', 'GLOBAL', 'GLOBAL'],
])
})
it('从配置读出来再写回去,值不变', () => {
// 这条是防止「显示的」和「写进文件的」跑偏:任何一种模式转一圈都要回到原值
for (const preset of OUTBOUND_PRESETS) {
expect(outboundToProxyField(preset.mode)).toBe(preset.field)
}
expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO')
})
it('界面上的名字用配置里的原词', () => {
expect(outboundLabel({ kind: 'rules' })).toBe('RULE')
expect(outboundLabel({ kind: 'direct' })).toBe('DIRECT')
expect(outboundLabel({ kind: 'global' })).toBe('GLOBAL')
expect(outboundLabel({ kind: 'named', name: '固定-Gomami' })).toBe('固定-Gomami')
})
it('只有 RULE 算「没明确指定」,界面据此弱化显示', () => {
expect(isUnsetOutbound({ kind: 'rules' })).toBe(true)
expect(isUnsetOutbound({ kind: 'direct' })).toBe(false)
expect(isUnsetOutbound({ kind: 'named', name: 'AUTO' })).toBe(false)
})
})
+45 -11
View File
@@ -55,23 +55,59 @@ export interface Inbound {
legacyKey: string | null
/** 在 listeners 数组里的下标,用于写回。旧写法为 null */
listenerIndex: number | null
/** 给人看的一句话说明,主要是给默认入站解释它为什么不能改出站 */
note: string | null
}
/**
* 不用指名道姓的那几种出站,以及它们各自对应配置里的什么值。
*
* 这张表是「读配置 → 界面 → 写回配置」三者的唯一约定来源。之前它被拆成四份散在
* 各处(读一份、显示一份、写回一份、表单里又抄了一份),加一种出站模式就要同时改
* 四个地方,漏一个就是「界面显示的」和「实际写进文件的」对不上。
*
* `field` 为 undefined 表示配置里**不写** proxy 这个字段——「没有这个键」和
* 「这个键是空字符串」在 YAML 里是两回事。
*/
export const OUTBOUND_PRESETS: Array<{
mode: OutboundMode
field: string | undefined
label: string
}> = [
{ mode: { kind: 'rules' }, field: undefined, label: 'RULE' },
{ mode: { kind: 'direct' }, field: 'DIRECT', label: 'DIRECT' },
{ mode: { kind: 'global' }, field: 'GLOBAL', label: 'GLOBAL' },
]
function outboundOf(proxy: unknown): OutboundMode {
if (typeof proxy !== 'string' || proxy === '') return { kind: 'rules' }
if (proxy === 'DIRECT') return { kind: 'direct' }
if (proxy === 'GLOBAL') return { kind: 'global' }
return { kind: 'named', name: proxy }
const preset = OUTBOUND_PRESETS.find((p) => p.field === proxy)
return preset ? preset.mode : { kind: 'named', name: proxy }
}
/** 把出站去向写回成配置里 proxy 字段该有的值。返回 undefined 表示不写这个字段。 */
export function outboundToProxyField(mode: OutboundMode): string | undefined {
if (mode.kind === 'rules') return undefined
if (mode.kind === 'direct') return 'DIRECT'
if (mode.kind === 'global') return 'GLOBAL'
return mode.name
if (mode.kind === 'named') return mode.name
return OUTBOUND_PRESETS.find((p) => p.mode.kind === mode.kind)?.field
}
/** 出站在界面上的名字。指名道姓的就是它自己的名字,其余用配置里的原词。 */
export function outboundLabel(mode: OutboundMode): string {
if (mode.kind === 'named') return mode.name
return OUTBOUND_PRESETS.find((p) => p.mode.kind === mode.kind)?.label ?? mode.kind
}
/**
* 是不是「没有明确指定」那种状态。
*
* 界面上用来决定要不要弱化显示:RULE 是不写 proxy 字段时的默认结果,不是用户做出的
* 选择,跟他明确选了 DIRECT 是两回事,不该长得一样。
*/
export function isUnsetOutbound(mode: OutboundMode): boolean {
return mode.kind === 'rules'
}
/** 入站类型在界面上怎么念。以配置里的原词为准,不另造描述。 */
export function inboundTypeLabel(type: string): string {
return type === '' ? '—' : type
}
/**
@@ -112,7 +148,6 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
isDefault: true,
legacyKey: key,
listenerIndex: null,
note: `由配置文件顶层的 ${key} 声明。这种写法没法单独指定出站,流量一律走分流规则。想让这个端口固定走某个出站,要把它改成命名入站。`,
})
}
@@ -131,7 +166,6 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
isDefault: false,
legacyKey: null,
listenerIndex: index,
note: null,
})
})
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'bun:test'
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
import type { RuntimeNode } from './outbound-chain.ts'
/** 仿照真实那台机器:PROXY 手动选,选中了自动测速的 AUTO,AUTO 挑了一条订阅线路。 */
const runtime: Record<string, RuntimeNode> = {
PROXY: { now: 'AUTO', all: ['AUTO', 'DIRECT'] },
AUTO: { now: '[vless]Gomami', all: ['[vmess]DMIT', '[vless]Gomami'] },
'[vless]Gomami': {},
'[vmess]DMIT': {},
DIRECT: {},
}
describe('解析流量最终走到哪条线', () => {
it('一路跟着当前选中走到底', () => {
expect(resolveChain(runtime, 'PROXY')).toEqual(['PROXY', 'AUTO', '[vless]Gomami'])
})
it('起点就是线路时链条只有它自己', () => {
expect(resolveChain(runtime, '[vless]Gomami')).toEqual(['[vless]Gomami'])
})
it('没有落脚点时是空链条', () => {
expect(resolveChain(runtime, null)).toEqual([])
})
it('落脚在一个内核不认识的名字上时也只返回它自己', () => {
// 配置引用了一个不存在的出站时会走到这里,界面要能显示而不是崩掉
expect(resolveChain(runtime, '不存在的组')).toEqual(['不存在的组'])
})
it('策略组互相引用成环时停下来,不会转死', () => {
const looped: Record<string, RuntimeNode> = {
A: { now: 'B', all: ['B'] },
B: { now: 'A', all: ['A'] },
}
expect(resolveChain(looped, 'A')).toEqual(['A', 'B'])
})
it('组选中了自己时也停得下来', () => {
expect(resolveChain({ A: { now: 'A', all: ['A'] } }, 'A')).toEqual(['A'])
})
})
describe('找出用户能换选中的那一层', () => {
it('取链条上最靠近线路的那个策略组', () => {
// 换 AUTO 的选中才是「换线路」;换 PROXY 换的是「走哪套策略」
const chain = resolveChain(runtime, 'PROXY')
expect(lastSwitchableGroup(runtime, chain)).toEqual({
group: 'AUTO',
members: ['[vmess]DMIT', '[vless]Gomami'],
now: '[vless]Gomami',
})
})
it('链条上一个策略组都没有时返回空', () => {
expect(lastSwitchableGroup(runtime, ['[vless]Gomami'])).toBeNull()
expect(lastSwitchableGroup(runtime, [])).toBeNull()
})
it('组存在但成员是空的,不算可切换', () => {
// 筛选条件没匹配到任何线路时会出现这种组,给出一个点不动的列表只会让人困惑
expect(lastSwitchableGroup({ 空组: { all: [], now: '' } }, ['空组'])).toBeNull()
})
})
+63
View File
@@ -0,0 +1,63 @@
/**
* 「这个端口现在到底走哪条线」——这个工具要回答的核心问题。
*
* 答案往往不是一跳能到的:一路入站落在 `PROXY` 组上,`PROXY` 选中了 `AUTO`,
* `AUTO` 又按延迟挑了某条线路。只显示第一层等于没回答。
*
* 这里只做纯计算,输入是内核报上来的运行时状态,输出是名字数组。放在 shared 里
* 是为了能被 `bun test` 覆盖——它带防环处理,而防环这种事写在组件的 computed 里
* 就再也没人测得到。
*/
/** 运行时状态里跟解析链路有关的部分。完整定义在 web/api.ts。 */
export interface RuntimeNode {
/** 策略组当前选中的成员。不是策略组就没有这个字段 */
now?: string
/** 策略组的成员列表 */
all?: string[]
}
/**
* 从落脚的出站一路跟着「当前选中」往下走。
*
* 返回的数组第一项是起点,最后一项是走到头的那个名字。起点为 null 时返回空数组。
* 策略组理论上可以互相引用成环,遇到重复的名字就停下——内核自己会拒绝这种配置,
* 但界面不能因为一份坏配置就转到卡死。
*/
export function resolveChain(
runtime: Record<string, RuntimeNode>,
start: string | null,
): string[] {
const hops: string[] = []
let cursor = start
while (cursor && !hops.includes(cursor)) {
hops.push(cursor)
cursor = runtime[cursor]?.now ?? null
}
return hops
}
export interface SwitchableGroup {
group: string
members: string[]
now: string
}
/**
* 链条上最后一个还是策略组的环节。
*
* 那是用户能真正做出选择的地方:往前的环节只是层层转交,往后已经是具体线路了。
* 取最后一个而不是第一个,是因为换最靠近线路的那一层才是「换线路」;换前面的层
* 换的是「走哪套策略」,那是另一回事。
*/
export function lastSwitchableGroup(
runtime: Record<string, RuntimeNode>,
chain: string[],
): SwitchableGroup | null {
for (let i = chain.length - 1; i >= 0; i -= 1) {
const name = chain[i]!
const node = runtime[name]
if (node?.all?.length) return { group: name, members: node.all, now: node.now ?? '' }
}
return null
}
+8 -5
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'bun:test'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { openDocument, replaceDocumentData } from '../vendor/config-editor/index.ts'
import { openDocument } from '../vendor/config-editor/index.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { rewriteYaml } from '../server/config-store.ts'
import { restoreInlineCommentAlignment } from './yaml-cosmetics.ts'
const fixture = readFileSync(
@@ -12,12 +13,14 @@ const fixture = readFileSync(
const clone = (v: unknown) => JSON.parse(JSON.stringify(v)) as ConfigObject
/** 走一遍真实的写回链路:解析 → 改 → 写回 → 修复格式。 */
/**
* 走**生产用的那一条**写回路径,而不是在测试里重拼一遍。
* 自己拼的话,落盘链路里漏掉其中一步这些测试照样会绿——那就白写了。
*/
function roundtrip(source: string, mutate: (draft: ConfigObject) => void): string {
const doc = openDocument(source)
const draft = clone(doc.data)
const draft = clone(openDocument(source).data)
mutate(draft)
return restoreInlineCommentAlignment(source, replaceDocumentData(doc, draft).yaml)
return rewriteYaml(source, draft)
}
describe('写回一份带注释的配置', () => {