搭起项目骨架,验证配置文件往返
核心是确认「改配置而不弄乱文件」这件事成立:新增一路入站之后,其余 52 行逐字节不变。 搬进来的改写引擎用默认输出参数时会重排整个文件——行尾注释的对齐空格消失、流式数组被加上内边距—— 连根本没改动的段落也一起波及。不修的话「改一处只动一处」这个承诺直接落空,所以关掉了它的自动排版, 并在写回之后补了一步逐行还原。
This commit is contained in:
Vendored
+197
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
applyPatch,
|
||||
ConfigDocumentError,
|
||||
diffDocument,
|
||||
findResourceReferences,
|
||||
openDocument,
|
||||
splitRuleFields,
|
||||
validateDocument,
|
||||
} from './index'
|
||||
|
||||
describe('config editor document', () => {
|
||||
it('rejects invalid YAML and non-mapping roots', () => {
|
||||
expect(() => openDocument('dns: [')).toThrow(ConfigDocumentError)
|
||||
expect(() => openDocument('- one\n- two\n')).toThrow(ConfigDocumentError)
|
||||
})
|
||||
|
||||
it('preserves untouched section comments while replacing touched sections', () => {
|
||||
const base = `# header\nmode: rule # keep\ndns:\n enable: true\n`
|
||||
const draft = `mode: rule\ndns:\n enable: false\n nameserver: [1.1.1.1]\n`
|
||||
const result = applyPatch(base, diffDocument(base, draft))
|
||||
expect(result.conflicts).toEqual([])
|
||||
expect(result.document.yaml).toContain('# header')
|
||||
expect(result.document.yaml).toContain('mode: rule # keep')
|
||||
expect(result.document.data.dns).toEqual({
|
||||
enable: false,
|
||||
nameserver: ['1.1.1.1'],
|
||||
})
|
||||
})
|
||||
|
||||
it('upserts, deletes, and reorders named resources', () => {
|
||||
const base = `proxies:\n - { name: a, type: ss, server: old }\n - { name: b, type: direct }\n`
|
||||
const draft = `proxies:\n - { name: c, type: direct }\n - { name: a, type: ss, server: new }\n`
|
||||
const patch = diffDocument(base, draft)
|
||||
expect(patch.operations.map((op) => op.op)).toEqual([
|
||||
'upsert',
|
||||
'delete',
|
||||
'upsert',
|
||||
'order',
|
||||
])
|
||||
const result = applyPatch(base, patch)
|
||||
expect(result.conflicts).toEqual([])
|
||||
expect(result.document.data.proxies).toEqual([
|
||||
{ name: 'c', type: 'direct' },
|
||||
{ name: 'a', type: 'ss', server: 'new' },
|
||||
])
|
||||
})
|
||||
|
||||
it('rebases an independent change and reports an overlapping change', () => {
|
||||
const base = `proxies:\n - { name: a, type: ss, server: one }\n - { name: b, type: direct }\n`
|
||||
const draft = `proxies:\n - { name: a, type: ss, server: two }\n - { name: b, type: direct }\n`
|
||||
const patch = diffDocument(base, draft)
|
||||
const independent = applyPatch(`${base}dns:\n enable: true\n`, patch)
|
||||
expect(independent.conflicts).toEqual([])
|
||||
|
||||
const changed = applyPatch(
|
||||
`proxies:\n - { name: a, type: ss, server: upstream }\n - { name: b, type: direct }\n`,
|
||||
patch,
|
||||
)
|
||||
expect(changed.conflicts).toHaveLength(1)
|
||||
expect(changed.conflicts[0]?.reason).toBe('changed')
|
||||
})
|
||||
|
||||
it('validates duplicate resources and broken references', () => {
|
||||
const diagnostics = validateDocument({
|
||||
proxies: [
|
||||
{ name: 'same', type: 'direct' },
|
||||
{ name: 'same', type: 'direct' },
|
||||
],
|
||||
'proxy-groups': [{ name: 'group', type: 'select', proxies: ['missing'] }],
|
||||
rules: ['MATCH,missing'],
|
||||
})
|
||||
expect(diagnostics.map((item) => item.code)).toEqual([
|
||||
'duplicate-name',
|
||||
'missing-reference',
|
||||
'missing-reference',
|
||||
])
|
||||
})
|
||||
|
||||
it('addresses rules by content identity instead of array index', () => {
|
||||
const base = `rules:\n - DOMAIN,one.test,DIRECT\n - MATCH,DIRECT\n`
|
||||
const draft = `rules:\n - DOMAIN,one.test,REJECT\n - MATCH,DIRECT\n`
|
||||
const patch = diffDocument(base, draft)
|
||||
expect(patch.operations[0]?.op).toBe('sequence-replace')
|
||||
|
||||
const refreshed = `rules:\n - DOMAIN,new.test,DIRECT\n - DOMAIN,one.test,DIRECT\n - MATCH,DIRECT\n`
|
||||
const result = applyPatch(refreshed, patch)
|
||||
expect(result.conflicts).toEqual([])
|
||||
expect(result.document.data.rules).toEqual([
|
||||
'DOMAIN,new.test,DIRECT',
|
||||
'DOMAIN,one.test,REJECT',
|
||||
'MATCH,DIRECT',
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a conflict when a reorder anchor disappeared upstream', () => {
|
||||
const base = `proxies:\n - { name: a, type: direct }\n - { name: b, type: direct }\n`
|
||||
const draft = `proxies:\n - { name: b, type: direct }\n - { name: a, type: direct }\n`
|
||||
const patch = diffDocument(base, draft)
|
||||
const result = applyPatch(
|
||||
`proxies:\n - { name: b, type: direct }\n`,
|
||||
patch,
|
||||
)
|
||||
expect(result.conflicts).toEqual([
|
||||
expect.objectContaining({ reason: 'missing' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('splits logical and quoted rules only at top-level commas', () => {
|
||||
expect(
|
||||
splitRuleFields(
|
||||
'AND,((DOMAIN,one.test),(NETWORK,UDP)),Proxy Group,no-resolve',
|
||||
),
|
||||
).toEqual([
|
||||
'AND',
|
||||
'((DOMAIN,one.test),(NETWORK,UDP))',
|
||||
'Proxy Group',
|
||||
'no-resolve',
|
||||
])
|
||||
expect(splitRuleFields('DOMAIN,"one,two.test",DIRECT')).toEqual([
|
||||
'DOMAIN',
|
||||
'"one,two.test"',
|
||||
'DIRECT',
|
||||
])
|
||||
})
|
||||
|
||||
it('finds named proxy references across routing and network sections', () => {
|
||||
const data = openDocument(`
|
||||
proxies:
|
||||
- { name: chained, type: direct, dialer-proxy: target }
|
||||
proxy-groups:
|
||||
- { name: group, type: select, proxies: [target] }
|
||||
proxy-providers:
|
||||
remote: { type: http, url: https://example.test, proxy: target }
|
||||
rule-providers:
|
||||
rules: { type: http, url: https://example.test, proxy: target }
|
||||
listeners:
|
||||
- { name: inbound, type: mixed, port: 7890, proxy: target }
|
||||
tunnels:
|
||||
- { network: tcp, address: 127.0.0.1:1, target: example.test:2, proxy: target }
|
||||
ntp: { enable: true, dialer-proxy: target }
|
||||
sub-rules:
|
||||
child:
|
||||
- MATCH,target
|
||||
rules:
|
||||
- AND,((DOMAIN,one.test),(NETWORK,UDP)),target
|
||||
- SUB-RULE,(NETWORK,TCP),target
|
||||
`).data
|
||||
expect(
|
||||
findResourceReferences(data, 'target').map((item) => item.kind),
|
||||
).toEqual([
|
||||
'proxy-group-member',
|
||||
'rule-policy',
|
||||
'rule-policy',
|
||||
'dialer-proxy',
|
||||
'dialer-proxy',
|
||||
'provider-proxy',
|
||||
'provider-proxy',
|
||||
'listener-proxy',
|
||||
'tunnel-proxy',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not treat unrelated same-name strings as proxy references', () => {
|
||||
const data = openDocument(`
|
||||
proxies:
|
||||
- { name: target, type: direct, server: target }
|
||||
hosts: { target: 127.0.0.1 }
|
||||
rules:
|
||||
- DOMAIN,target,DIRECT
|
||||
`).data
|
||||
expect(findResourceReferences(data, 'target')).toEqual([])
|
||||
})
|
||||
|
||||
it('validates missing references in sub-rules and dialer fields', () => {
|
||||
const diagnostics = validateDocument({
|
||||
proxies: [{ name: 'node', type: 'direct', 'dialer-proxy': 'missing' }],
|
||||
'sub-rules': { child: ['MATCH,missing'] },
|
||||
rules: ['SUB-RULE,(NETWORK,TCP),absent', 'MATCH,GLOBAL'],
|
||||
})
|
||||
expect(diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ['sub-rules', 'child', 0],
|
||||
severity: 'warning',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: ['proxies', 0, 'dialer-proxy'],
|
||||
severity: 'error',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: ['rules', 0],
|
||||
message: 'Unknown sub-rule: absent',
|
||||
severity: 'error',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user