迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
68 lines
5.4 KiB
TypeScript
68 lines
5.4 KiB
TypeScript
import { afterAll, describe, expect, it } from 'vitest';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { clientSupports, nodeInputSchema, type NodeDto } from '@proxy-station/shared';
|
|
import { generateSurge, surgeNodeLine } from './surge.js';
|
|
import type { ProfileIR } from '../ir.js';
|
|
|
|
const node: NodeDto = {
|
|
...nodeInputSchema.parse({ name: 'JP VMess', protocol: 'vmess', server: 'example.com', port: 443, uuid: '00000000-0000-0000-0000-000000000000', tls: true }),
|
|
id: 'fixture', createdAt: 0, updatedAt: 0, extra: {},
|
|
} as NodeDto;
|
|
const base: ProfileIR = { nodes: [node], groups: [], rules: [{ type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null }], sections: {} };
|
|
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-surge-'));
|
|
const cli = process.env.SURGE_CLI ?? 'surge-cli';
|
|
const nativeAvailable = spawnSync(cli, ['--help'], { timeout: 10000 }).status === 0;
|
|
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
|
|
|
describe('Surge safe serialization and supported semantics', () => {
|
|
it('preserves literals and every filtered group source, including with include-all', () => {
|
|
const groups: ProfileIR['groups'] = ['Source A', 'Source B'].map((name) => ({ name, type: 'select', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null }));
|
|
groups.push({ ...groups[0], name: 'Filtered', includeAllNodes: true, filterRegex: '^JP|SG', members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'group', name: 'Source A' }, { kind: 'group', name: 'Source B' }] });
|
|
const content = generateSurge({ ...base, groups }, { selfUrl: 'https://example.com/sub/test/surge' }).content;
|
|
expect(content).toContain('Filtered = select, DIRECT, include-all-proxies=true, include-other-group="Source A,Source B", policy-regex-filter=^JP|SG');
|
|
const all = { ...groups[0], name: 'All', includeAllNodes: true, members: [{ kind: 'builtin' as const, name: 'DIRECT' }] };
|
|
expect(generateSurge({ ...base, groups: [all] }, { selfUrl: 'https://example.com/sub/test/surge' }).content).toContain('All = select, DIRECT, "JP VMess"');
|
|
});
|
|
|
|
it('maps TLS and cipher flags and reports unsupported VMess combinations', () => {
|
|
expect(surgeNodeLine({ ...node, skipCertVerify: true, security: 'chacha20-poly1305' })).toContain('skip-cert-verify=true, vmess-aead=true, encrypt-method=chacha20-ietf-poly1305');
|
|
expect(clientSupports('surge', { ...node, alterId: 64 }).ok).toBe(false);
|
|
expect(clientSupports('surge', { ...node, security: 'none' }).ok).toBe(false);
|
|
const result = generateSurge({ ...base, nodes: [{ ...node, alterId: 64 }] }, { selfUrl: 'https://example.com/sub/test/surge' });
|
|
expect(result.excluded).toHaveLength(1);
|
|
expect(result.content).not.toContain('JP VMess =');
|
|
});
|
|
|
|
it('uses one trailing FINAL and omits obsolete group url and fallback tolerance', () => {
|
|
const groups: ProfileIR['groups'] = [{ name: 'Test', type: 'fallback', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: 'https://example.com/test', interval: 300, tolerance: 100 }];
|
|
const content = generateSurge({ ...base, groups, rules: [base.rules[0], { type: 'DOMAIN', value: 'example.org', policy: 'DIRECT', params: [], ruleset: null }] }, { selfUrl: 'https://example.com/sub/test/surge' }).content;
|
|
expect(content).not.toContain('url=');
|
|
expect(content).not.toContain('tolerance=');
|
|
expect(content).toContain('DOMAIN,example.org,DIRECT\nFINAL,DIRECT');
|
|
expect(generateSurge({ ...base, rules: [] }, { selfUrl: 'https://example.com/sub/test/surge' }).content).toContain('FINAL,DIRECT');
|
|
});
|
|
|
|
it.skipIf(!nativeAvailable)('passes the official local CLI with commas, regex escaping, filtered imports and TLS flags', () => {
|
|
const groups: ProfileIR['groups'] = ['A', 'B'].map((name) => ({ name, type: 'select', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null }));
|
|
groups.push({ ...groups[0], name: 'Filtered', includeAllNodes: true, filterRegex: '^(JP|SG),?', members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'group', name: 'A' }, { kind: 'group', name: 'B' }] });
|
|
const cases: ProfileIR[] = [
|
|
{ ...base, groups },
|
|
{ ...base, rules: [{ type: 'URL-REGEX', value: '^https://example.com/(a|b),?c\\d', policy: 'DIRECT', params: [], ruleset: null }, ...base.rules] },
|
|
{ ...base, nodes: [{ ...node, protocol: 'trojan', password: 'placeholder,with"quote\\backslash', network: 'ws', wsPath: '/a,b', wsHost: 'example.com' }] },
|
|
{ ...base, nodes: [{ ...node, skipCertVerify: true, security: 'chacha20-poly1305' }] },
|
|
{ ...base, rules: [{ type: 'AND', value: '((PROTOCOL,UDP),(DOMAIN-SUFFIX,example.com))', policy: 'REJECT-NO-DROP', params: ['notification-text=matched, example'], ruleset: null }, ...base.rules] },
|
|
{ ...base, rules: [] },
|
|
];
|
|
for (const [i, ir] of cases.entries()) {
|
|
const file = path.join(directory, `${i}.conf`);
|
|
writeFileSync(file, generateSurge(ir, { selfUrl: 'https://example.com/sub/test/surge' }).content, { mode: 0o600 });
|
|
const result = spawnSync(cli, ['--check', file], { encoding: 'utf8', timeout: 15000 });
|
|
expect(result.stdout + result.stderr).toContain('OK');
|
|
expect(result.status).toBe(0);
|
|
}
|
|
});
|
|
});
|