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); } }); });