Files
sub-store/internal/render/render_extra_test.go
T
rogee 9ec8c956c1 feat: sing-box Clash template support + Docker CI + frontend scaffold
- Clash→sing-box template conversion engine (singbox_template.go)
- Auto-map 40+ Clash rule-providers to sing-box .srs binary rule-sets
- Fix sing-box 1.11+/1.12+ migrations (sniff/block/WireGuard/DNS/rule_set)
- Fix WireGuard URI '+' parsing bug (rawParamGet)
- Add REJECT block outbound for selector group references
- Skip rules referencing rule-sets with no sing-box equivalent
- Verified: sing-box run succeeds with ACL4SSR template (16 rule-sets loaded)
- Add Dockerfile (multi-stage Go build)
- Add GitHub Actions workflow (multi-arch: amd64+arm64, push to ghcr.io)
- Add frontend scaffold (Vue3 + Vite + Tailwind)
2026-07-27 21:56:19 +08:00

2922 lines
80 KiB
Go

package render
import (
"encoding/base64"
"encoding/json"
"net/url"
"reflect"
"strings"
"testing"
"gopkg.in/yaml.v3"
"github.com/peterqiu0516/sub-store/internal/model"
)
// --- test fixtures ---
// allNodeTypes returns one node per supported proxy type, with realistic
// fields that exercise every renderer branch.
func allNodeTypes() []model.ProxyNode {
return []model.ProxyNode{
{
"type": "ss",
"name": "SS-Node",
"server": "ss.example.com",
"port": 8388,
"cipher": "aes-256-gcm",
"password": "ss-pass",
"udp": true,
"tfo": true,
"skip-cert-verify": true,
"alpn": []any{"h2", "http/1.1"},
"plugin": "obfs",
"plugin-opts": map[string]any{"mode": "http", "host": "obfs.example.com", "path": "/obfs"},
},
{
"type": "vmess",
"name": "VMess-Node",
"server": "vmess.example.com",
"port": 443,
"uuid": "vmess-uuid",
"alterId": 64,
"cipher": "auto",
"network": "ws",
"tls": true,
"servername": "vmess.sni.com",
"udp": true,
"ws-opts": map[string]any{
"path": "/vmess",
"headers": map[string]any{"Host": "vmess.example.com"},
},
},
{
"type": "vless",
"name": "VLESS-Node",
"server": "vless.example.com",
"port": 443,
"uuid": "vless-uuid",
"network": "ws",
"tls": true,
"servername": "vless.sni.com",
"flow": "xtls-rprx-vision",
"client-fingerprint": "chrome",
"reality-opts": map[string]any{
"public-key": "realitypubkey",
"short-id": "abc123",
"spider-x": "/spider",
},
},
{
"type": "trojan",
"name": "Trojan-Node",
"server": "trojan.example.com",
"port": 443,
"password": "trojan-pass",
"sni": "trojan.sni.com",
"udp": true,
"skip-cert-verify": true,
},
{
"type": "hysteria2",
"name": "Hy2-Node",
"server": "hy2.example.com",
"port": 443,
"password": "hy2-pass",
"sni": "hy2.sni.com",
"obfs": "salamander",
"obfs-password": "obfs-pwd",
"skip-cert-verify": true,
},
{
"type": "tuic",
"name": "TUIC-Node",
"server": "tuic.example.com",
"port": 443,
"uuid": "tuic-uuid",
"password": "tuic-pass",
"sni": "tuic.sni.com",
"udp-relay-mode": "quic",
"congestion-controller": "bbr",
"reduce-rtt": true,
"disable-sni": true,
"skip-cert-verify": true,
},
{
"type": "http",
"name": "HTTP-Node",
"server": "http.example.com",
"port": 8080,
"username": "http-user",
"password": "http-pass",
"tls": true,
"sni": "http.sni.com",
},
{
"type": "socks5",
"name": "SOCKS5-Node",
"server": "socks.example.com",
"port": 1080,
"username": "socks-user",
"password": "socks-pass",
"tls": true,
},
{
"type": "anytls",
"name": "AnyTLS-Node",
"server": "anytls.example.com",
"port": 443,
"password": "anytls-pass",
"sni": "anytls.sni.com",
"skip-cert-verify": true,
"client-fingerprint": "chrome",
},
{
"type": "wireguard",
"name": "WG-Node",
"server": "wg.example.com",
"port": 51820,
"private-key": "wg-privkey",
"public-key": "wg-pubkey",
"pre-shared-key": "wg-psk",
"ip": "10.0.0.2/32",
"ipv6": "fd00::2/128",
"reserved": "1,2,3",
},
{
"type": "snell",
"name": "Snell-Node",
"server": "snell.example.com",
"port": 443,
"psk": "snell-psk",
"version": 4,
},
{
"type": "ssh",
"name": "SSH-Node",
"server": "ssh.example.com",
"port": 22,
"username": "ssh-user",
"password": "ssh-pass",
"private-key": "ssh-privkey",
},
{
"type": "h2-connect",
"name": "H2-Node",
"server": "h2.example.com",
"port": 443,
"username": "h2-user",
"password": "h2-pass",
"tls": true,
"sni": "h2.sni.com",
},
{
"type": "ssr",
"name": "SSR-Node",
"server": "ssr.example.com",
"port": 8388,
"cipher": "aes-256-cfb",
"password": "ssr-pass",
"protocol": "auth_aes128_md5",
"obfs": "tls1.2_ticket_auth",
"protocol-param": "param1",
"obfs-param": "param2",
},
{
"type": "hysteria",
"name": "Hy-Node",
"server": "hy.example.com",
"port": 443,
"auth_str": "hy-auth",
"up": 100,
"down": 200,
"sni": "hy.sni.com",
"protocol": "udp",
"obfs": "salamander",
"obfs-password": "hy-obfs",
"skip-cert-verify": true,
},
}
}
// nodeByName extracts a single node from allNodeTypes by name prefix.
// Matches the first node whose name starts with the given prefix.
func nodeByName(name string) model.ProxyNode {
for _, n := range allNodeTypes() {
if strings.HasPrefix(getString(n, "name"), name) {
return n
}
}
return nil
}
// nodeByExactName extracts a single node from allNodeTypes by exact name match.
func nodeByExactName(name string) model.ProxyNode {
for _, n := range allNodeTypes() {
if getString(n, "name") == name {
return n
}
}
return nil
}
// --- dispatch: RenderTarget coverage ---
func TestExtraRenderTargetAll(t *testing.T) {
nodes := allNodeTypes()
targets := []string{
model.TargetMihomo, model.TargetStash,
model.TargetSurge, model.TargetSurgeMac,
model.TargetSurfboard, model.TargetLoon,
model.TargetEgern, model.TargetQX,
model.TargetSingBox, model.TargetV2ray,
model.TargetURI, model.TargetShadowrocket,
model.TargetJSON,
}
for _, tgt := range targets {
t.Run(tgt, func(t *testing.T) {
out, err := RenderTarget(nodes, tgt, nil)
// Every target must either succeed or error with no-nodes;
// since we have 15 nodes, success is expected for all.
if err != nil {
// Surge/surfboard may reject unsupported types, but at least
// one node should be supported — so err here is unexpected.
t.Fatalf("RenderTarget(%q) returned error: %v", tgt, err)
}
if strings.TrimSpace(out) == "" {
t.Fatalf("RenderTarget(%q) returned empty output", tgt)
}
})
}
}
func TestExtraRenderTargetEmptyNodes(t *testing.T) {
empty := []model.ProxyNode{}
for _, tgt := range []string{
model.TargetMihomo, model.TargetSurge, model.TargetSingBox,
model.TargetV2ray, model.TargetURI, model.TargetJSON,
model.TargetLoon, model.TargetEgern, model.TargetQX,
model.TargetSurfboard, model.TargetSurgeMac,
} {
t.Run(tgt, func(t *testing.T) {
_, err := RenderTarget(empty, tgt, nil)
if err == nil {
t.Fatalf("expected error for empty nodes with target %q", tgt)
}
})
}
}
func TestExtraRenderTargetUnsupportedFallsBackToMihomo(t *testing.T) {
nodes := allNodeTypes()[:2]
out, err := RenderTarget(nodes, "no-such-target", nil)
if err != nil {
t.Fatalf("unsupported target should fall back to mihomo, got err: %v", err)
}
if !strings.Contains(out, "Generated by Sub-Store") {
t.Errorf("expected mihomo fallback output, got: %s", out)
}
}
func TestExtraRenderBuildTargetAll(t *testing.T) {
nodes := allNodeTypes()
for _, tgt := range []string{
model.TargetMihomo, model.TargetStash,
model.TargetSurge, model.TargetSurgeMac,
model.TargetSurfboard, model.TargetLoon,
model.TargetEgern, model.TargetShadowrocket,
model.TargetQX, model.TargetSingBox,
model.TargetV2ray, model.TargetURI, model.TargetJSON,
} {
t.Run(tgt, func(t *testing.T) {
out, err := RenderBuildTarget(nodes, tgt, "https://sub.example.com/api/sub?target=x", nil)
if err != nil {
t.Fatalf("RenderBuildTarget(%q) error: %v", tgt, err)
}
if strings.TrimSpace(out) == "" {
t.Fatalf("RenderBuildTarget(%q) empty output", tgt)
}
})
}
}
func TestExtraRenderBuildTargetFallback(t *testing.T) {
nodes := allNodeTypes()[:1]
out, err := RenderBuildTarget(nodes, "unknown-target", "https://sub.example.com/path", nil)
if err != nil {
t.Fatalf("fallback should succeed: %v", err)
}
if !strings.Contains(out, "/path") {
t.Errorf("expected requestUrl path /path in output, got: %s", out)
}
}
func TestExtraSourcePath(t *testing.T) {
cases := []struct {
in, want string
}{
{"https://sub.example.com/api/sub", "/api/sub"},
{"https://sub.example.com", "sub.example.com"},
{"not-a-url", "not-a-url"},
{"", ""},
{"https://sub.example.com/path?a=b", "/path"},
}
for _, c := range cases {
got := sourcePath(c.in)
if got != c.want {
t.Errorf("sourcePath(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// --- Surge renderer ---
func TestExtraSurgeProxyLineAllTypes(t *testing.T) {
cases := []struct {
name string
node model.ProxyNode
wantSub []string
}{
{"ss", nodeByName("SS"), []string{"SS-Node", "=ss,"}},
{"vmess", nodeByName("VMess"), []string{"=vmess,", "vmess-uuid", "encrypt-method=auto"}},
{"trojan", nodeByName("Trojan"), []string{"=trojan,", "trojan-pass"}},
{"http", nodeByName("HTTP"), []string{"=https,", "http.example.com", "8080"}},
{"socks5-tls", nodeByName("SOCKS5"), []string{"=socks5-tls,"}},
{"hysteria2", nodeByName("Hy2"), []string{"=hysteria2,", "hy2-pass", "obfs=salamander"}},
{"tuic", nodeByName("TUIC"), []string{"=tuic-v5,", "tuic-uuid"}},
{"anytls", nodeByName("AnyTLS"), []string{"=anytls,"}},
{"snell", nodeByName("Snell"), []string{"=snell,", "snell-psk", "version=4"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
line := ToSurgeProxyLine(c.node)
if line == "" {
t.Fatalf("ToSurgeProxyLine(%s) returned empty", c.name)
}
for _, sub := range c.wantSub {
if !strings.Contains(line, sub) {
t.Errorf("expected %q in line: %s", sub, line)
}
}
})
}
}
func TestExtraSurgeUnsupportedType(t *testing.T) {
node := model.ProxyNode{"type": "wireguard", "name": "wg", "server": "x", "port": 1}
if line := ToSurgeProxyLine(node); line != "" {
t.Errorf("expected empty for unsupported type, got: %s", line)
}
}
func TestExtraSurgeSocks5WithoutTls(t *testing.T) {
node := model.ProxyNode{
"type": "socks5", "name": "socks", "server": "s", "port": 1080,
"username": "u", "password": "p", "tls": false,
}
line := ToSurgeProxyLine(node)
if !strings.Contains(line, "=socks5,") {
t.Errorf("expected socks5 (not socks5-tls): %s", line)
}
}
func TestExtraSurgeHTTPWithoutTls(t *testing.T) {
node := model.ProxyNode{
"type": "http", "name": "h", "server": "s", "port": 80,
"tls": false,
}
line := ToSurgeProxyLine(node)
if !strings.Contains(line, "=http,") {
t.Errorf("expected http (not https): %s", line)
}
}
func TestExtraSurgeHysteria2NoObfs(t *testing.T) {
node := model.ProxyNode{
"type": "hysteria2", "name": "hy2", "server": "s", "port": 443,
"password": "p", "sni": "sni.com",
}
line := ToSurgeProxyLine(node)
if strings.Contains(line, "obfs=") {
t.Errorf("expected no obfs in line: %s", line)
}
}
func TestExtraSurgeSnellDefaultVersion(t *testing.T) {
node := model.ProxyNode{
"type": "snell", "name": "snell", "server": "s", "port": 443,
"password": "p",
}
line := ToSurgeProxyLine(node)
if !strings.Contains(line, "version=3") {
t.Errorf("expected default version=3: %s", line)
}
}
func TestExtraSurgeSnellPskFallback(t *testing.T) {
node := model.ProxyNode{
"type": "snell", "name": "snell", "server": "s", "port": 443,
"password": "fallback-pwd",
}
line := ToSurgeProxyLine(node)
if !strings.Contains(line, "psk=fallback-pwd") {
t.Errorf("expected psk=fallback-pwd: %s", line)
}
}
// --- Surge-Mac renderer ---
func TestExtraSurgeMacSpecificTypes(t *testing.T) {
cases := []struct {
name string
node model.ProxyNode
want []string
}{
{"ssh", nodeByName("SSH"), []string{"=ssh,", "ssh.example.com", "ssh-user", "private-key=ssh-privkey"}},
{"h2-connect", nodeByName("H2"), []string{"=h2-connect,", "h2.example.com", "tls=true"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
line := ToSurgeMacProxyLine(c.node)
if line == "" {
t.Fatalf("ToSurgeMacProxyLine(%s) returned empty", c.name)
}
for _, w := range c.want {
if !strings.Contains(line, w) {
t.Errorf("expected %q in line: %s", w, line)
}
}
})
}
}
func TestExtraSurgeMacFallsBackToSurge(t *testing.T) {
node := nodeByName("SS")
macLine := ToSurgeMacProxyLine(node)
surgeLine := ToSurgeProxyLine(node)
if macLine != surgeLine {
t.Errorf("surge-mac should delegate ss to surge; mac=%q surge=%q", macLine, surgeLine)
}
}
func TestExtraRenderSurgeMacProxies(t *testing.T) {
nodes := allNodeTypes()
out, err := RenderSurgeMacProxies(nodes)
if err != nil {
t.Fatalf("RenderSurgeMacProxies failed: %v", err)
}
if !strings.Contains(out, "SSH-Node=ssh,") {
t.Errorf("expected ssh line in surge-mac output: %s", out)
}
if !strings.Contains(out, "H2-Node=h2-connect,") {
t.Errorf("expected h2-connect line in surge-mac output: %s", out)
}
}
// --- Surfboard renderer ---
func TestExtraSurfboardProxies(t *testing.T) {
nodes := allNodeTypes()
out, err := RenderSurfboardProxies(nodes)
if err != nil {
t.Fatalf("RenderSurfboardProxies failed: %v", err)
}
if !strings.Contains(out, "SS-Node") {
t.Errorf("expected SS-Node in surfboard output: %s", out)
}
// Surfboard does not support hysteria2/tuic/wireguard etc.
if strings.Contains(out, "Hy2-Node") {
t.Errorf("surfboard should not include hysteria2: %s", out)
}
}
func TestExtraSurfboardEmptyNodes(t *testing.T) {
_, err := RenderSurfboardProxies([]model.ProxyNode{})
if err == nil {
t.Fatal("expected error for empty nodes")
}
}
// --- Loon renderer ---
func TestExtraLoonProxyLineAllTypes(t *testing.T) {
cases := []struct {
name string
node model.ProxyNode
wantSub []string
}{
{"ss", nodeByName("SS"), []string{"=shadowsocks,", "ss.example.com", "8388"}},
{"ssr", nodeByName("SSR"), []string{"=shadowsocksr,"}},
{"vmess", nodeByName("VMess"), []string{"=vmess,", "vmess-uuid"}},
{"vless", nodeByName("VLESS"), []string{"=vless,", "vless-uuid"}},
{"trojan", nodeByName("Trojan"), []string{"=trojan,"}},
{"anytls", nodeByName("AnyTLS"), []string{"=anytls,"}},
{"http", nodeByName("HTTP"), []string{"=http,"}},
{"socks5", nodeByName("SOCKS5"), []string{"=socks5,"}},
{"hysteria2", nodeByName("Hy2"), []string{"=Hysteria2,"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
line := ToLoonProxyLine(c.node)
if line == "" {
t.Fatalf("ToLoonProxyLine(%s) empty", c.name)
}
for _, sub := range c.wantSub {
if !strings.Contains(line, sub) {
t.Errorf("expected %q in: %s", sub, line)
}
}
})
}
}
func TestExtraLoonUnsupportedType(t *testing.T) {
node := model.ProxyNode{"type": "tuic", "name": "x", "server": "s", "port": 1}
if line := ToLoonProxyLine(node); line != "" {
t.Errorf("tuic should be unsupported in loon, got: %s", line)
}
}
func TestExtraLoonVlessMethodNone(t *testing.T) {
node := model.ProxyNode{
"type": "vless", "name": "vl", "server": "s", "port": 443,
"uuid": "u",
}
line := ToLoonProxyLine(node)
if !strings.Contains(line, ",none,") {
t.Errorf("expected method=none for vless: %s", line)
}
}
func TestExtraLoonSsDefaultCipher(t *testing.T) {
node := model.ProxyNode{
"type": "ss", "name": "x", "server": "s", "port": 1,
"password": "p",
}
line := ToLoonProxyLine(node)
if !strings.Contains(line, ",none,") {
t.Errorf("expected default cipher=none: %s", line)
}
}
// --- QX renderer ---
func TestExtraQxProxyLineAllTypes(t *testing.T) {
cases := []struct {
name string
node model.ProxyNode
wantSub []string
}{
{"ss", nodeByName("SS"), []string{"shadowsocks=ss.example.com:8388", "tag=SS-Node"}},
{"ssr", nodeByName("SSR"), []string{"shadowsocks=ssr.example.com:8388", "ssr-protocol=auth_aes128_md5"}},
{"vmess", nodeByName("VMess"), []string{"vmess=vmess.example.com:443", "method=auto"}},
{"vless", nodeByName("VLESS"), []string{"vless=vless.example.com:443", "method=none"}},
{"trojan", nodeByName("Trojan"), []string{"trojan=trojan.example.com:443", "over-tls=true"}},
{"anytls", nodeByName("AnyTLS"), []string{"anytls=anytls.example.com:443"}},
{"http", nodeByName("HTTP"), []string{"http=http.example.com:8080"}},
{"socks5", nodeByName("SOCKS5"), []string{"socks5=socks.example.com:1080"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
line := ToQxProxyLine(c.node)
if line == "" {
t.Fatalf("ToQxProxyLine(%s) empty", c.name)
}
for _, sub := range c.wantSub {
if !strings.Contains(line, sub) {
t.Errorf("expected %q in: %s", sub, line)
}
}
})
}
}
func TestExtraQxUnsupportedType(t *testing.T) {
node := model.ProxyNode{"type": "wireguard", "name": "x", "server": "s", "port": 1}
if line := ToQxProxyLine(node); line != "" {
t.Errorf("wireguard should be unsupported in qx, got: %s", line)
}
}
func TestExtraQxWsTransportWss(t *testing.T) {
node := model.ProxyNode{
"type": "vmess", "name": "n", "server": "s", "port": 443,
"uuid": "u", "network": "ws", "tls": true,
"ws-opts": map[string]any{"path": "/ws", "headers": map[string]any{"Host": "h.com"}},
}
line := ToQxProxyLine(node)
if !strings.Contains(line, "obfs=wss") {
t.Errorf("expected obfs=wss for tls+ws: %s", line)
}
}
func TestExtraQxWsTransportWs(t *testing.T) {
node := model.ProxyNode{
"type": "vmess", "name": "n", "server": "s", "port": 80,
"uuid": "u", "network": "ws", "tls": false,
"ws-opts": map[string]any{"path": "/ws"},
}
line := ToQxProxyLine(node)
if !strings.Contains(line, "obfs=ws") {
t.Errorf("expected obfs=ws for plain ws: %s", line)
}
}
func TestExtraQxEmptyName(t *testing.T) {
node := model.ProxyNode{
"type": "ss", "name": "", "server": "s", "port": 1,
"cipher": "c", "password": "p",
}
line := ToQxProxyLine(node)
if !strings.Contains(line, "tag=proxy") {
t.Errorf("expected tag=proxy for empty name: %s", line)
}
}
func TestExtraSanitizeQxTag(t *testing.T) {
cases := []struct{ in, want string }{
{"name,with,commas", "name with commas"},
{"name\r\n", "name"},
{"=", "="}, // = is NOT replaced in qx tag
{"", "proxy"},
}
for _, c := range cases {
got := sanitizeQxTag(c.in)
if got != c.want {
t.Errorf("sanitizeQxTag(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// --- Egern renderer ---
func TestExtraEgernAllTypes(t *testing.T) {
cases := []struct {
name string
node model.ProxyNode
wantKey map[string]string
}{
{"ss", nodeByName("SS"), map[string]string{"type": "shadowsocks"}},
{"vmess", nodeByName("VMess"), map[string]string{"type": "vmess", "security": "auto"}},
{"vless", nodeByName("VLESS"), map[string]string{"type": "vless"}},
{"trojan", nodeByName("Trojan"), map[string]string{"type": "trojan"}},
{"anytls", nodeByName("AnyTLS"), map[string]string{"type": "anytls"}},
{"hysteria2", nodeByName("Hy2"), map[string]string{"type": "hysteria2"}},
{"http", nodeByName("HTTP"), map[string]string{"type": "https"}},
{"socks5", nodeByName("SOCKS5"), map[string]string{"type": "socks5_tls"}},
{"tuic", nodeByName("TUIC"), map[string]string{"type": "tuic"}},
{"wireguard", nodeByName("WG"), map[string]string{"type": "wireguard", "private_key": "wg-privkey"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m := ToEgernProxy(c.node)
if m == nil {
t.Fatalf("ToEgernProxy(%s) nil", c.name)
}
for k, v := range c.wantKey {
got, ok := m[k]
if !ok {
t.Errorf("missing key %q in egern output: %v", k, m)
continue
}
if s, _ := got.(string); s != v {
t.Errorf("egern[%q] = %v, want %q", k, got, v)
}
}
})
}
}
func TestExtraEgernUnsupportedType(t *testing.T) {
node := model.ProxyNode{"type": "snell", "name": "x"}
if m := ToEgernProxy(node); m != nil {
t.Errorf("snell should be unsupported in egern, got: %v", m)
}
}
func TestExtraEgernHTTPWithoutTls(t *testing.T) {
node := model.ProxyNode{
"type": "http", "name": "h", "server": "s", "port": 80, "tls": false,
}
m := ToEgernProxy(node)
if m["type"] != "http" {
t.Errorf("expected type=http, got: %v", m["type"])
}
}
func TestExtraEgernSocks5WithoutTls(t *testing.T) {
node := model.ProxyNode{
"type": "socks5", "name": "s", "server": "s", "port": 1, "tls": false,
}
m := ToEgernProxy(node)
if m["type"] != "socks5" {
t.Errorf("expected type=socks5, got: %v", m["type"])
}
}
func TestExtraEgernWsOptions(t *testing.T) {
node := model.ProxyNode{
"type": "vmess", "name": "v", "server": "s", "port": 443,
"uuid": "u", "network": "ws",
"ws-opts": map[string]any{
"path": "/ws",
"headers": map[string]any{"Host": "h.com"},
},
}
m := ToEgernProxy(node)
ws, ok := m["ws_opts"].(map[string]any)
if !ok {
t.Fatalf("expected ws_opts map, got: %v", m["ws_opts"])
}
if ws["path"] != "/ws" {
t.Errorf("expected ws path /ws, got: %v", ws["path"])
}
if _, ok := ws["headers"]; !ok {
t.Errorf("expected headers in ws_opts")
}
}
func TestExtraEgernWsOptionsNoHeaders(t *testing.T) {
node := model.ProxyNode{
"type": "vmess", "name": "v", "server": "s", "port": 443,
"uuid": "u", "network": "ws",
"ws-opts": map[string]any{"path": "/ws"},
}
m := ToEgernProxy(node)
ws, _ := m["ws_opts"].(map[string]any)
if _, ok := ws["headers"]; ok {
t.Errorf("expected no headers in ws_opts when absent")
}
}
func TestExtraEgernWsOptionsNonWs(t *testing.T) {
node := model.ProxyNode{
"type": "vmess", "name": "v", "server": "s", "port": 443,
"uuid": "u", "network": "tcp",
}
m := ToEgernProxy(node)
if _, ok := m["ws_opts"]; ok {
t.Errorf("expected no ws_opts for tcp network")
}
}
func TestExtraEgernRealityOptions(t *testing.T) {
node := model.ProxyNode{
"type": "vless", "name": "v", "server": "s", "port": 443,
"uuid": "u",
"reality-opts": map[string]any{
"public-key": "pk",
"short-id": "sid",
},
}
m := ToEgernProxy(node)
r, ok := m["reality"].(map[string]any)
if !ok {
t.Fatalf("expected reality map, got: %v", m["reality"])
}
if r["public_key"] != "pk" {
t.Errorf("expected public_key=pk, got: %v", r["public_key"])
}
}
func TestExtraEgernRealityNoPubKey(t *testing.T) {
node := model.ProxyNode{
"type": "vless", "name": "v", "server": "s", "port": 443,
"uuid": "u",
"reality-opts": map[string]any{"short-id": "sid"},
}
m := ToEgernProxy(node)
if _, ok := m["reality"]; ok {
t.Errorf("expected no reality without public-key")
}
}
func TestExtraEgernRenderYaml(t *testing.T) {
nodes := allNodeTypes()
out, err := RenderEgernYaml(nodes)
if err != nil {
t.Fatalf("RenderEgernYaml failed: %v", err)
}
var parsed map[string]any
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("egern output not valid YAML: %v\n%s", err, out)
}
proxies, ok := parsed["proxies"].([]any)
if !ok {
t.Fatal("expected proxies array in egern yaml")
}
if len(proxies) == 0 {
t.Error("expected at least one proxy in egern yaml")
}
}
func TestExtraEgernRenderYamlEmpty(t *testing.T) {
_, err := RenderEgernYaml([]model.ProxyNode{})
if err == nil {
t.Fatal("expected error for empty egern")
}
}
func TestExtraEgernRenderYamlAllUnsupported(t *testing.T) {
nodes := []model.ProxyNode{
{"type": "snell", "name": "x"},
}
_, err := RenderEgernYaml(nodes)
if err == nil {
t.Fatal("expected error when no supported nodes")
}
}
func TestExtraNoNodesError(t *testing.T) {
e := errNoNodes("test")
if e.Error() != "No supported nodes for test output" {
t.Errorf("unexpected error message: %s", e.Error())
}
}
func TestExtraMergeMap(t *testing.T) {
base := map[string]any{"a": 1, "b": 2}
next := map[string]any{"b": 3, "c": 4}
merged := mergeMap(base, next)
if merged["a"] != 1 || merged["b"] != 3 || merged["c"] != 4 {
t.Errorf("mergeMap unexpected: %v", merged)
}
// originals should not be mutated
if base["b"] != 2 {
t.Errorf("mergeMap mutated base")
}
}
// --- sing-box renderer ---
func TestExtraSingBoxStructure(t *testing.T) {
nodes := allNodeTypes()
out := RenderSingBoxJson(nodes)
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("sing-box output not valid JSON: %v", err)
}
// Per review-resolution #42: log, inbounds, outbounds, route
for _, key := range []string{"log", "inbounds", "outbounds", "route"} {
if _, ok := parsed[key]; !ok {
t.Errorf("missing %q in sing-box output", key)
}
}
logMap, _ := parsed["log"].(map[string]any)
if logMap == nil || logMap["level"] != "info" {
t.Errorf("expected log.level=info, got: %v", parsed["log"])
}
inbounds, _ := parsed["inbounds"].([]any)
if len(inbounds) == 0 {
t.Fatal("expected at least one inbound")
}
inbound, _ := inbounds[0].(map[string]any)
if inbound["type"] != "mixed" || inbound["tag"] != "mixed-in" {
t.Errorf("unexpected inbound: %v", inbound)
}
if inbound["listen_port"] != float64(7890) {
t.Errorf("expected listen_port=7890, got: %v", inbound["listen_port"])
}
outbounds, _ := parsed["outbounds"].([]any)
if len(outbounds) < 3 {
t.Fatalf("expected at least 3 outbounds (PROXY, AUTO, nodes, DIRECT), got %d", len(outbounds))
}
proxyOut, _ := outbounds[0].(map[string]any)
if proxyOut["tag"] != "PROXY" || proxyOut["type"] != "selector" {
t.Errorf("expected first outbound PROXY selector, got: %v", proxyOut)
}
autoOut, _ := outbounds[1].(map[string]any)
if autoOut["tag"] != "AUTO" || autoOut["type"] != "urltest" {
t.Errorf("expected second outbound AUTO urltest, got: %v", autoOut)
}
// DIRECT should be at the end (REJECT is now a route rule action, not a special outbound)
last, _ := outbounds[len(outbounds)-1].(map[string]any)
if last["tag"] != "DIRECT" || last["type"] != "direct" {
t.Errorf("expected last outbound DIRECT direct, got: %v", last)
}
// Inbound should NOT have sniff field (migrated to route rule action in sing-box 1.11+)
if _, hasSniff := inbound["sniff"]; hasSniff {
t.Errorf("inbound should not have sniff field (migrated to route rule action)")
}
// Route should have rules with sniff and reject actions
routeMap, _ := parsed["route"].(map[string]any)
rules, _ := routeMap["rules"].([]any)
if len(rules) < 2 {
t.Fatalf("expected at least 2 route rules (sniff, reject), got %d", len(rules))
}
rule0, _ := rules[0].(map[string]any)
if rule0["action"] != "sniff" {
t.Errorf("expected first route rule action=sniff, got: %v", rule0["action"])
}
rule1, _ := rules[1].(map[string]any)
if rule1["action"] != "reject" {
t.Errorf("expected second route rule action=reject, got: %v", rule1["action"])
}
if routeMap == nil || routeMap["final"] != "PROXY" {
t.Errorf("expected route.final=PROXY, got: %v", parsed["route"])
}
if routeMap["auto_detect_interface"] != true {
t.Errorf("expected auto_detect_interface=true, got: %v", routeMap["auto_detect_interface"])
}
}
func TestExtraSingBoxOutboundAllTypes(t *testing.T) {
cases := []struct {
name string
node model.ProxyNode
wantType string
}{
{"ss", nodeByName("SS"), "shadowsocks"},
{"vmess", nodeByName("VMess"), "vmess"},
{"vless", nodeByName("VLESS"), "vless"},
{"trojan", nodeByName("Trojan"), "trojan"},
{"hysteria2", nodeByExactName("Hy2-Node"), "hysteria2"},
{"hysteria", nodeByExactName("Hy-Node"), "hysteria"},
{"anytls", nodeByName("AnyTLS"), "anytls"},
{"tuic", nodeByName("TUIC"), "tuic"},
{"socks5", nodeByName("SOCKS5"), "socks"},
{"http", nodeByName("HTTP"), "http"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
out := ToSingBoxOutbound(c.node)
if out == nil {
t.Fatalf("ToSingBoxOutbound(%s) nil", c.name)
}
if out["type"] != c.wantType {
t.Errorf("sing-box[%s].type = %v, want %q", c.name, out["type"], c.wantType)
}
if out["tag"] != getString(c.node, "name") {
t.Errorf("sing-box[%s].tag = %v, want %q", c.name, out["tag"], getString(c.node, "name"))
}
})
}
}
func TestExtraSingBoxWireGuardEndpoint(t *testing.T) {
node := nodeByName("WG")
ep := ToSingBoxWireGuardEndpoint(node)
if ep == nil {
t.Fatalf("ToSingBoxWireGuardEndpoint(WG) nil")
}
if ep["type"] != "wireguard" {
t.Errorf("expected type=wireguard, got %v", ep["type"])
}
if ep["tag"] != "WG-Node" {
t.Errorf("expected tag=WG-Node, got %v", ep["tag"])
}
peers, ok := ep["peers"].([]any)
if !ok || len(peers) == 0 {
t.Fatalf("expected peers array, got: %v", ep["peers"])
}
peer, ok := peers[0].(map[string]any)
if !ok {
t.Fatalf("expected peer map, got: %v", peers[0])
}
if peer["public_key"] == nil {
t.Errorf("expected peer.public_key to be set")
}
}
func TestExtraSingBoxUnsupportedType(t *testing.T) {
node := model.ProxyNode{"type": "snell", "name": "x"}
if out := ToSingBoxOutbound(node); out != nil {
t.Errorf("snell should be unsupported in sing-box, got: %v", out)
}
}
func TestExtraSingBoxVlessReality(t *testing.T) {
node := nodeByName("VLESS")
out := ToSingBoxOutbound(node)
tls, ok := out["tls"].(map[string]any)
if !ok {
t.Fatalf("expected tls map, got: %v", out["tls"])
}
reality, ok := tls["reality"].(map[string]any)
if !ok {
t.Fatalf("expected reality map under tls, got: %v", tls["reality"])
}
if reality["public_key"] != "realitypubkey" {
t.Errorf("expected reality public_key, got: %v", reality["public_key"])
}
}
func TestExtraSingBoxVlessNoTls(t *testing.T) {
node := model.ProxyNode{
"type": "vless", "name": "v", "server": "s", "port": 443,
"uuid": "u", "tls": false,
}
out := ToSingBoxOutbound(node)
if _, ok := out["tls"]; ok {
t.Errorf("expected no tls field when tls=false")
}
}
func TestExtraSingBoxHysteria2Obfs(t *testing.T) {
node := nodeByName("Hy2")
out := ToSingBoxOutbound(node)
obfs, ok := out["obfs"].(map[string]any)
if !ok {
t.Fatalf("expected obfs map, got: %v", out["obfs"])
}
if obfs["type"] != "salamander" {
t.Errorf("expected obfs.type=salamander, got: %v", obfs["type"])
}
}
func TestExtraSingBoxHysteria2NoObfs(t *testing.T) {
node := model.ProxyNode{
"type": "hysteria2", "name": "h", "server": "s", "port": 443,
"password": "p",
}
out := ToSingBoxOutbound(node)
if _, ok := out["obfs"]; ok {
t.Errorf("expected no obfs field when obfs absent")
}
}
func TestExtraSingBoxSocks5Tls(t *testing.T) {
node := nodeByName("SOCKS5")
out := ToSingBoxOutbound(node)
tls, ok := out["tls"].(map[string]any)
if !ok || tls["enabled"] != true {
t.Errorf("expected tls.enabled=true for socks5+tls, got: %v", out["tls"])
}
}
func TestExtraSingBoxSocks5NoTls(t *testing.T) {
node := model.ProxyNode{
"type": "socks5", "name": "s", "server": "s", "port": 1,
"tls": false,
}
out := ToSingBoxOutbound(node)
if _, ok := out["tls"]; ok {
t.Errorf("expected no tls field for plain socks5")
}
}
func TestExtraSingBoxHTTPTls(t *testing.T) {
node := nodeByName("HTTP")
out := ToSingBoxOutbound(node)
tls, ok := out["tls"].(map[string]any)
if !ok || tls["enabled"] != true {
t.Errorf("expected tls.enabled=true for https, got: %v", out["tls"])
}
}
func TestExtraSingBoxWireGuardReserved(t *testing.T) {
node := nodeByName("WG")
ep := ToSingBoxWireGuardEndpoint(node)
peers, ok := ep["peers"].([]any)
if !ok || len(peers) == 0 {
t.Fatalf("expected peers array, got: %v", ep["peers"])
}
peer := peers[0].(map[string]any)
reserved, ok := peer["reserved"].([]int)
if !ok {
t.Fatalf("expected peer reserved []int, got: %v", peer["reserved"])
}
if !reflect.DeepEqual(reserved, []int{1, 2, 3}) {
t.Errorf("expected reserved=[1,2,3], got: %v", reserved)
}
}
func TestExtraSingBoxWireGuardLocalAddress(t *testing.T) {
node := nodeByName("WG")
ep := ToSingBoxWireGuardEndpoint(node)
addr, ok := ep["address"].([]string)
if !ok {
t.Fatalf("expected address, got: %v", ep["address"])
}
if len(addr) != 2 {
t.Errorf("expected 2 addresses (ip+ipv6), got %d", len(addr))
}
}
func TestExtraSingBoxVmessTransport(t *testing.T) {
node := nodeByName("VMess")
out := ToSingBoxOutbound(node)
transport, ok := out["transport"].(map[string]any)
if !ok {
t.Fatalf("expected transport map, got: %v", out["transport"])
}
if transport["type"] != "ws" {
t.Errorf("expected transport.type=ws, got: %v", transport["type"])
}
}
func TestExtraSingBoxVmessNoTransport(t *testing.T) {
node := model.ProxyNode{
"type": "vmess", "name": "v", "server": "s", "port": 443,
"uuid": "u", "network": "tcp",
}
out := ToSingBoxOutbound(node)
if _, ok := out["transport"]; ok {
t.Errorf("expected no transport for tcp vmess")
}
}
func TestExtraSingBoxVmessNoTls(t *testing.T) {
node := model.ProxyNode{
"type": "vmess", "name": "v", "server": "s", "port": 443,
"uuid": "u", "tls": false,
}
out := ToSingBoxOutbound(node)
if _, ok := out["tls"]; ok {
t.Errorf("expected no tls field for plain vmess")
}
}
func TestExtraParseWireGuardReserved(t *testing.T) {
cases := []struct {
name string
input any
want []int
}{
{"array", []any{1, 2, 3}, []int{1, 2, 3}},
{"array_with_zero", []any{0, 1, 2}, []int{1, 2}},
{"array_empty", []any{}, nil},
{"int_slice", []int{1, 2}, []int{1, 2}},
{"string", "1,2,3", []int{1, 2, 3}},
{"empty_string", "", nil},
{"nil", nil, nil},
{"other_type", 42, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := parseWireGuardReserved(c.input)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("parseWireGuardReserved(%v) = %v, want %v", c.input, got, c.want)
}
})
}
}
func TestExtraSplitComma(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"a,b,c", []string{"a", "b", "c"}},
{"", []string{""}},
{"abc", []string{"abc"}},
{",a", []string{"", "a"}},
{"a,", []string{"a", ""}},
}
for _, c := range cases {
got := splitComma(c.in)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("splitComma(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// --- URI renderer ---
func TestExtraToProxyUriAllTypes(t *testing.T) {
cases := []struct {
name string
node model.ProxyNode
scheme string
contains string
}{
{"ss", nodeByName("SS"), "ss://", ""},
{"ssr", nodeByName("SSR"), "ssr://", ""},
{"vmess", nodeByName("VMess"), "vmess://", ""},
{"vless", nodeByName("VLESS"), "vless://", "vless-uuid"},
{"trojan", nodeByName("Trojan"), "trojan://", "trojan-pass"},
{"hysteria2", nodeByExactName("Hy2-Node"), "hysteria2://", "hy2-pass"},
{"hysteria", nodeByExactName("Hy-Node"), "hysteria://", "hy-auth"},
{"anytls", nodeByName("AnyTLS"), "anytls://", "anytls-pass"},
{"tuic", nodeByName("TUIC"), "tuic://", "tuic-uuid"},
{"socks5", nodeByName("SOCKS5"), "socks5+tls://", ""},
{"http", nodeByName("HTTP"), "https://", ""},
{"wireguard", nodeByName("WG"), "wireguard://", "wg-privkey"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
uri := ToProxyUri(c.node)
if uri == "" {
t.Fatalf("ToProxyUri(%s) empty", c.name)
}
if !strings.HasPrefix(uri, c.scheme) {
t.Errorf("expected scheme %q, got: %s", c.scheme, uri)
}
if c.contains != "" && !strings.Contains(uri, c.contains) {
t.Errorf("expected %q in uri: %s", c.contains, uri)
}
})
}
}
func TestExtraToProxyUriUnsupported(t *testing.T) {
node := model.ProxyNode{"type": "snell", "name": "x"}
if uri := ToProxyUri(node); uri != "" {
t.Errorf("expected empty for snell, got: %s", uri)
}
}
func TestExtraToProxyUriVlessSecurityNone(t *testing.T) {
node := model.ProxyNode{
"type": "vless", "name": "v", "server": "s", "port": 443,
"uuid": "u", "tls": false,
}
uri := ToProxyUri(node)
if !strings.Contains(uri, "security=none") {
t.Errorf("expected security=none: %s", uri)
}
}
func TestExtraToProxyUriVlessSecurityTls(t *testing.T) {
node := model.ProxyNode{
"type": "vless", "name": "v", "server": "s", "port": 443,
"uuid": "u", "tls": true, "servername": "sni.com",
}
uri := ToProxyUri(node)
if !strings.Contains(uri, "security=tls") {
t.Errorf("expected security=tls: %s", uri)
}
if !strings.Contains(uri, "sni=sni.com") {
t.Errorf("expected sni=sni.com: %s", uri)
}
}
func TestExtraToProxyUriVlessRealitySpxFallback(t *testing.T) {
node := model.ProxyNode{
"type": "vless", "name": "v", "server": "s", "port": 443,
"uuid": "u", "tls": true,
"reality-opts": map[string]any{
"public-key": "pk",
"short-id": "sid",
// spider-x missing → spx should fallback to "/"
},
}
uri := ToProxyUri(node)
if !strings.Contains(uri, "spx=%2F") { // url-encoded "/"
t.Errorf("expected spx=/ fallback: %s", uri)
}
}
func TestExtraToProxyUriVlessFlow(t *testing.T) {
node := model.ProxyNode{
"type": "vless", "name": "v", "server": "s", "port": 443,
"uuid": "u", "tls": true, "flow": "xtls-rprx-vision",
}
uri := ToProxyUri(node)
if !strings.Contains(uri, "flow=xtls-rprx-vision") {
t.Errorf("expected flow=xtls-rprx-vision: %s", uri)
}
}
func TestExtraToProxyUriHysteria2Insecure(t *testing.T) {
node := model.ProxyNode{
"type": "hysteria2", "name": "h", "server": "s", "port": 443,
"password": "p", "skip-cert-verify": true,
}
uri := ToProxyUri(node)
if !strings.Contains(uri, "insecure=1") {
t.Errorf("expected insecure=1: %s", uri)
}
}
func TestExtraToProxyUriHysteria2NoObfs(t *testing.T) {
node := model.ProxyNode{
"type": "hysteria2", "name": "h", "server": "s", "port": 443,
"password": "p",
}
uri := ToProxyUri(node)
if strings.Contains(uri, "obfs=") {
t.Errorf("expected no obfs param: %s", uri)
}
}
func TestExtraToProxyUriTrojanAllowInsecure(t *testing.T) {
node := model.ProxyNode{
"type": "trojan", "name": "t", "server": "s", "port": 443,
"password": "p", "skip-cert-verify": true,
}
uri := ToProxyUri(node)
if !strings.Contains(uri, "allowInsecure=1") {
t.Errorf("expected allowInsecure=1: %s", uri)
}
}
func TestExtraToProxyUriSocks5NoTls(t *testing.T) {
node := model.ProxyNode{
"type": "socks5", "name": "s", "server": "s", "port": 1,
"tls": false,
}
uri := ToProxyUri(node)
if !strings.HasPrefix(uri, "socks5://") {
t.Errorf("expected socks5:// (not socks5+tls): %s", uri)
}
}
func TestExtraToProxyUriSocks5WithAuth(t *testing.T) {
node := model.ProxyNode{
"type": "socks5", "name": "s", "server": "s", "port": 1,
"username": "u", "password": "p", "tls": false,
}
uri := ToProxyUri(node)
if !strings.Contains(uri, "u:p@") {
t.Errorf("expected auth in uri: %s", uri)
}
}
func TestExtraToProxyUriHTTPNoTls(t *testing.T) {
node := model.ProxyNode{
"type": "http", "name": "h", "server": "s", "port": 80, "tls": false,
}
uri := ToProxyUri(node)
if !strings.HasPrefix(uri, "http://") {
t.Errorf("expected http://: %s", uri)
}
}
func TestExtraToProxyUriHTTPWithAuth(t *testing.T) {
node := model.ProxyNode{
"type": "http", "name": "h", "server": "s", "port": 80,
"username": "u", "password": "p",
}
uri := ToProxyUri(node)
if !strings.Contains(uri, "u:p@") {
t.Errorf("expected auth in uri: %s", uri)
}
}
func TestExtraToProxyUriSsBase64(t *testing.T) {
node := model.ProxyNode{
"type": "ss", "name": "x", "server": "s", "port": 1,
"cipher": "aes-256-gcm", "password": "p",
}
uri := ToProxyUri(node)
// ss://base64(cipher:password@server:port)#name
if !strings.HasPrefix(uri, "ss://") {
t.Fatalf("expected ss:// prefix: %s", uri)
}
parts := strings.SplitN(uri, "#", 2)
if len(parts) != 2 {
t.Fatalf("expected fragment in uri: %s", uri)
}
b64 := strings.TrimPrefix(parts[0], "ss://")
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
t.Fatalf("ss userinfo not valid base64: %v", err)
}
if !strings.Contains(string(decoded), "aes-256-gcm:p@s:1") {
t.Errorf("unexpected decoded ss userinfo: %s", decoded)
}
}
func TestExtraToProxyUriSsr(t *testing.T) {
node := nodeByName("SSR")
uri := ToProxyUri(node)
if !strings.HasPrefix(uri, "ssr://") {
t.Fatalf("expected ssr:// prefix: %s", uri)
}
}
func TestExtraToProxyUriVmess(t *testing.T) {
node := nodeByName("VMess")
uri := ToProxyUri(node)
if !strings.HasPrefix(uri, "vmess://") {
t.Fatalf("expected vmess:// prefix: %s", uri)
}
b64 := strings.TrimPrefix(uri, "vmess://")
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
t.Fatalf("vmess payload not valid base64: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(decoded, &parsed); err != nil {
t.Fatalf("vmess payload not valid JSON: %v", err)
}
if parsed["v"] != "2" {
t.Errorf("expected v=2, got: %v", parsed["v"])
}
if parsed["add"] != "vmess.example.com" {
t.Errorf("expected add=vmess.example.com, got: %v", parsed["add"])
}
if parsed["net"] != "ws" {
t.Errorf("expected net=ws, got: %v", parsed["net"])
}
}
func TestExtraToProxyUriVmessNoPort(t *testing.T) {
node := model.ProxyNode{
"type": "vmess", "name": "v", "server": "s",
"uuid": "u",
}
uri := ToProxyUri(node)
b64 := strings.TrimPrefix(uri, "vmess://")
decoded, _ := base64.StdEncoding.DecodeString(b64)
var parsed map[string]any
json.Unmarshal(decoded, &parsed)
if parsed["port"] != "" {
t.Errorf("expected empty port for 0, got: %v", parsed["port"])
}
}
func TestExtraRenderProxyUris(t *testing.T) {
nodes := allNodeTypes()
out := RenderProxyUris(nodes)
lines := strings.Split(out, "\n")
// 15 total nodes, but snell/ssh/h2-connect are not supported by URI renderer
// so we expect 12 URIs
wantLines := 12
if len(lines) != wantLines {
t.Errorf("expected %d lines, got %d", wantLines, len(lines))
}
}
func TestExtraRenderProxyUrisWithUnsupported(t *testing.T) {
nodes := []model.ProxyNode{
{"type": "snell", "name": "x"}, // unsupported
{"type": "ss", "name": "y", "server": "s", "port": 1, "cipher": "c", "password": "p"},
}
out := RenderProxyUris(nodes)
if out == "" {
t.Fatal("expected non-empty output")
}
if strings.Contains(out, "snell") {
t.Errorf("snell should be filtered out: %s", out)
}
}
func TestExtraSniStr(t *testing.T) {
cases := []struct {
proxy model.ProxyNode
keys []string
want string
}{
{model.ProxyNode{"sni": "a"}, []string{"sni", "servername"}, "a"},
{model.ProxyNode{"servername": "b"}, []string{"sni", "servername"}, "b"},
{model.ProxyNode{}, []string{"sni", "servername"}, ""},
{model.ProxyNode{"sni": ""}, []string{"sni"}, ""},
}
for _, c := range cases {
got := sniStr(c.proxy, c.keys...)
if got != c.want {
t.Errorf("sniStr(%v, %v) = %q, want %q", c.proxy, c.keys, got, c.want)
}
}
}
func TestExtraAidStr(t *testing.T) {
cases := []struct {
in any
want string
}{
{nil, "0"},
{0, "0"},
{int64(64), "64"},
{float64(128), "128"},
{"", "0"},
{"256", "256"},
{true, "0"},
}
for _, c := range cases {
got := aidStr(c.in)
if got != c.want {
t.Errorf("aidStr(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestExtraBoolToStr(t *testing.T) {
if boolToStr(true, "yes", "no") != "yes" {
t.Error("boolToStr(true) failed")
}
if boolToStr(false, "yes", "no") != "no" {
t.Error("boolToStr(false) failed")
}
}
func TestExtraWsHeaderHostKey(t *testing.T) {
cases := []struct {
name string
wsOpts map[string]any
want string
}{
{"Host capital", map[string]any{"headers": map[string]any{"Host": "h.com"}}, "h.com"},
{"host lowercase", map[string]any{"headers": map[string]any{"host": "h.com"}}, "h.com"},
{"no headers", map[string]any{}, ""},
{"nil", nil, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := WsHeaderHostKey(c.wsOpts)
if got != c.want {
t.Errorf("WsHeaderHostKey(%v) = %q, want %q", c.wsOpts, got, c.want)
}
})
}
}
// --- textutil.go tests ---
func TestExtraRenderTextProxyList(t *testing.T) {
nodes := []model.ProxyNode{
{"type": "ss", "name": "a"},
{"type": "ss", "name": "b"},
}
producer := func(p model.ProxyNode) string {
return getString(p, "name")
}
out, err := RenderTextProxyList(nodes, "test", producer)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != "a\nb" {
t.Errorf("expected 'a\\nb', got: %q", out)
}
}
func TestExtraRenderTextProxyListSkipEmpty(t *testing.T) {
nodes := []model.ProxyNode{
{"type": "ss", "name": "a"},
{"type": "ss", "name": "skip"}, // producer returns ""
{"type": "ss", "name": "b"},
}
producer := func(p model.ProxyNode) string {
if getString(p, "name") == "skip" {
return ""
}
return getString(p, "name")
}
out, err := RenderTextProxyList(nodes, "test", producer)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != "a\nb" {
t.Errorf("expected 'a\\nb', got: %q", out)
}
}
func TestExtraRenderTextProxyListAllEmpty(t *testing.T) {
nodes := []model.ProxyNode{
{"type": "ss", "name": "a"},
{"type": "ss", "name": "b"},
}
producer := func(p model.ProxyNode) string { return "" }
_, err := RenderTextProxyList(nodes, "test", producer)
if err == nil {
t.Fatal("expected error when all producers return empty")
}
}
func TestExtraJoinTextProxy(t *testing.T) {
entries := [][2]any{
{"a", "1"},
{"b", "2"},
{"c", nil}, // should be dropped
{"d", ""}, // should be dropped
{"e", true},
{"f", false},
}
got := JoinTextProxy("base", entries)
want := "base,a=1,b=2,e=true,f=false"
if got != want {
t.Errorf("JoinTextProxy = %q, want %q", got, want)
}
}
func TestExtraJoinTextProxyNoEntries(t *testing.T) {
got := JoinTextProxy("base", nil)
if got != "base" {
t.Errorf("expected base only, got: %q", got)
}
}
func TestExtraJoinTextProxyAllNilEntries(t *testing.T) {
entries := [][2]any{
{"a", nil},
{"b", ""},
}
got := JoinTextProxy("base", entries)
if got != "base" {
t.Errorf("expected base only when all entries nil/empty, got: %q", got)
}
}
func TestExtraFormatTextOptionValue(t *testing.T) {
cases := []struct {
name string
input any
want string
}{
{"bool_true", true, "true"},
{"bool_false", false, "false"},
{"nil", nil, ""},
{"int", 42, "42"},
{"string_plain", "hello", "hello"},
{"string_with_comma", "a,b", `"a,b"`},
{"string_with_space", "a b", `"a b"`},
{"string_with_quote", `a"b`, `"a\"b"`},
{"array_any", []any{1, 2, 3}, `"1,2,3"`},
{"array_string", []string{"a", "b"}, `"a,b"`},
{"array_empty", []any{}, `""`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := FormatTextOptionValue(c.input)
if got != c.want {
t.Errorf("FormatTextOptionValue(%v) = %q, want %q", c.input, got, c.want)
}
})
}
}
func TestExtraQuoteTextValue(t *testing.T) {
cases := []struct {
in any
want string
}{
{nil, `""`},
{"hello", `"hello"`},
{`a"b`, `"a\"b"`},
{42, `"42"`},
{"", `""`},
}
for _, c := range cases {
got := QuoteTextValue(c.in)
if got != c.want {
t.Errorf("QuoteTextValue(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestExtraSanitizeTextProxyName(t *testing.T) {
cases := []struct {
in string
want string
}{
{"name", "name"},
{"name=test", "name test"},
{"name,with,comma", "name with comma"},
{"name\r\n", "name"},
{"", "proxy"},
{" ", "proxy"},
{"=,\r\n", "proxy"},
{" name ", "name"},
}
for _, c := range cases {
got := SanitizeTextProxyName(c.in)
if got != c.want {
t.Errorf("SanitizeTextProxyName(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestExtraCommonTextOptions(t *testing.T) {
proxy := model.ProxyNode{
"skip-cert-verify": true,
"udp": true,
"tfo": true,
"alpn": []any{"h2", "http/1.1"},
}
entries := CommonTextOptions(proxy)
m := make(map[string]any)
for _, e := range entries {
m[e[0].(string)] = e[1]
}
if m["skip-cert-verify"] != true {
t.Error("expected skip-cert-verify=true")
}
if m["udp-relay"] != true {
t.Error("expected udp-relay=true")
}
if m["fast-open"] != true {
t.Error("expected fast-open=true (from tfo)")
}
if m["alpn"] != "h2,http/1.1" {
t.Errorf("expected alpn=h2,http/1.1, got: %v", m["alpn"])
}
}
func TestExtraCommonTextOptionsFastOpenFallback(t *testing.T) {
proxy := model.ProxyNode{
"fast-open": true,
}
entries := CommonTextOptions(proxy)
m := make(map[string]any)
for _, e := range entries {
m[e[0].(string)] = e[1]
}
if m["fast-open"] != true {
t.Error("expected fast-open=true from fallback")
}
}
func TestExtraFastOpenValue(t *testing.T) {
// tfo takes priority over fast-open
if v := fastOpenValue(model.ProxyNode{"tfo": "x", "fast-open": "y"}); v != "x" {
t.Errorf("expected tfo=x to win, got: %v", v)
}
// falls back to fast-open
if v := fastOpenValue(model.ProxyNode{"fast-open": "y"}); v != "y" {
t.Errorf("expected fast-open=y, got: %v", v)
}
// both nil
if v := fastOpenValue(model.ProxyNode{}); v != nil {
t.Errorf("expected nil, got: %v", v)
}
}
func TestExtraAppendWsOptionsSurge(t *testing.T) {
proxy := model.ProxyNode{
"network": "ws",
"ws-opts": map[string]any{
"path": "/ws",
"headers": map[string]any{"Host": "h.com"},
},
}
entries := AppendWsOptions([][2]any{}, proxy, "surge")
m := entriesToMap(entries)
if m["ws"] != true {
t.Error("expected ws=true")
}
if m["ws-path"] != "/ws" {
t.Errorf("expected ws-path=/ws, got: %v", m["ws-path"])
}
if m["ws-headers"] != "h.com" {
t.Errorf("expected ws-headers=h.com, got: %v", m["ws-headers"])
}
}
func TestExtraAppendWsOptionsNonSurge(t *testing.T) {
proxy := model.ProxyNode{
"network": "ws",
"ws-opts": map[string]any{"path": "/ws", "headers": map[string]any{"Host": "h"}},
}
entries := AppendWsOptions([][2]any{}, proxy, "loon")
m := entriesToMap(entries)
if _, ok := m["ws"]; ok {
t.Error("expected no ws=true for non-surge")
}
if m["path"] != "/ws" {
t.Errorf("expected path=/ws, got: %v", m["path"])
}
if m["host"] != "h" {
t.Errorf("expected host=h, got: %v", m["host"])
}
}
func TestExtraAppendWsOptionsNonWs(t *testing.T) {
proxy := model.ProxyNode{"network": "tcp"}
entries := AppendWsOptions([][2]any{{"x", "y"}}, proxy, "surge")
if len(entries) != 1 {
t.Errorf("expected unchanged entries for non-ws, got: %v", entries)
}
}
func TestExtraAppendWsOptionsDefaultPath(t *testing.T) {
proxy := model.ProxyNode{"network": "ws"}
entries := AppendWsOptions([][2]any{}, proxy, "surge")
m := entriesToMap(entries)
if m["ws-path"] != "/" {
t.Errorf("expected default ws-path=/, got: %v", m["ws-path"])
}
}
func TestExtraAppendPluginOptionsSurge(t *testing.T) {
proxy := model.ProxyNode{
"plugin": "obfs",
"plugin-opts": map[string]any{
"mode": "http", "host": "h.com", "path": "/p",
},
}
entries := AppendPluginOptions([][2]any{}, proxy, "surge")
m := entriesToMap(entries)
if m["obfs"] != "http" {
t.Errorf("expected obfs=http, got: %v", m["obfs"])
}
if m["obfs-host"] != "h.com" {
t.Errorf("expected obfs-host=h.com, got: %v", m["obfs-host"])
}
if m["obfs-uri"] != "/p" {
t.Errorf("expected obfs-uri=/p, got: %v", m["obfs-uri"])
}
}
func TestExtraAppendPluginOptionsNonSurge(t *testing.T) {
proxy := model.ProxyNode{
"plugin": "obfs",
"plugin-opts": map[string]any{
"mode": "http", "host": "h.com", "path": "/p",
},
}
entries := AppendPluginOptions([][2]any{}, proxy, "loon")
m := entriesToMap(entries)
if m["obfs-name"] != "http" {
t.Errorf("expected obfs-name=http, got: %v", m["obfs-name"])
}
}
func TestExtraAppendPluginOptionsNonObfs(t *testing.T) {
proxy := model.ProxyNode{"plugin": "other"}
entries := AppendPluginOptions([][2]any{{"x", "y"}}, proxy, "surge")
if len(entries) != 1 {
t.Errorf("expected unchanged entries for non-obfs, got: %v", entries)
}
}
func TestExtraAppendPluginOptionsNoOpts(t *testing.T) {
proxy := model.ProxyNode{"plugin": "obfs"}
entries := AppendPluginOptions([][2]any{{"x", "y"}}, proxy, "surge")
if len(entries) != 1 {
t.Errorf("expected unchanged entries when no plugin-opts, got: %v", entries)
}
}
func TestExtraAppendRealityOptions(t *testing.T) {
proxy := model.ProxyNode{
"reality-opts": map[string]any{
"public-key": "pk",
"short-id": "sid",
},
}
entries := AppendRealityOptions([][2]any{}, proxy)
m := entriesToMap(entries)
if m["public-key"] != "pk" {
t.Errorf("expected public-key=pk, got: %v", m["public-key"])
}
if m["short-id"] != "sid" {
t.Errorf("expected short-id=sid, got: %v", m["short-id"])
}
}
func TestExtraAppendRealityOptionsNoOpts(t *testing.T) {
proxy := model.ProxyNode{}
entries := AppendRealityOptions([][2]any{}, proxy)
// AppendRealityOptions always appends entries, even when reality-opts is nil.
// The nil values would be dropped by JoinTextProxy, but the entries exist.
if len(entries) != 2 {
t.Fatalf("expected 2 entries (public-key, short-id), got %d", len(entries))
}
if entries[0][0] != "public-key" || entries[0][1] != nil {
t.Errorf("expected public-key=nil, got: %v", entries[0])
}
if entries[1][0] != "short-id" || entries[1][1] != nil {
t.Errorf("expected short-id=nil, got: %v", entries[1])
}
}
func TestExtraAppendQxRealityOptions(t *testing.T) {
proxy := model.ProxyNode{
"reality-opts": map[string]any{
"public-key": "pk",
"short-id": "sid",
},
}
entries := AppendQxRealityOptions([][2]any{}, proxy)
m := entriesToMap(entries)
if m["reality-base64-pubkey"] != "pk" {
t.Errorf("expected reality-base64-pubkey=pk, got: %v", m["reality-base64-pubkey"])
}
if m["reality-hex-shortid"] != "sid" {
t.Errorf("expected reality-hex-shortid=sid, got: %v", m["reality-hex-shortid"])
}
}
func TestExtraAppendQxObfs(t *testing.T) {
proxy := model.ProxyNode{
"plugin": "obfs",
"plugin-opts": map[string]any{
"mode": "http", "host": "h.com", "path": "/p",
},
}
entries := AppendQxObfs([][2]any{}, proxy)
m := entriesToMap(entries)
if m["obfs"] != "http" {
t.Errorf("expected obfs=http, got: %v", m["obfs"])
}
}
func TestExtraAppendQxObfsNonObfs(t *testing.T) {
proxy := model.ProxyNode{"plugin": "other"}
entries := AppendQxObfs([][2]any{{"x", "y"}}, proxy)
if len(entries) != 1 {
t.Errorf("expected unchanged for non-obfs, got: %v", entries)
}
}
func TestExtraAppendQxObfsNoOpts(t *testing.T) {
proxy := model.ProxyNode{"plugin": "obfs"}
entries := AppendQxObfs([][2]any{{"x", "y"}}, proxy)
if len(entries) != 1 {
t.Errorf("expected unchanged when no plugin-opts, got: %v", entries)
}
}
func TestExtraAppendQxTransport(t *testing.T) {
proxy := model.ProxyNode{
"network": "ws",
"tls": true,
"ws-opts": map[string]any{
"path": "/ws",
"headers": map[string]any{"Host": "h.com"},
},
}
entries := AppendQxTransport([][2]any{}, proxy)
m := entriesToMap(entries)
if m["obfs"] != "wss" {
t.Errorf("expected obfs=wss, got: %v", m["obfs"])
}
if m["obfs-uri"] != "/ws" {
t.Errorf("expected obfs-uri=/ws, got: %v", m["obfs-uri"])
}
}
func TestExtraAppendQxTransportNonWs(t *testing.T) {
proxy := model.ProxyNode{"network": "tcp"}
entries := AppendQxTransport([][2]any{{"x", "y"}}, proxy)
if len(entries) != 1 {
t.Errorf("expected unchanged for non-ws, got: %v", entries)
}
}
func TestExtraWsHeaderHost(t *testing.T) {
cases := []struct {
name string
wsOpts map[string]any
want string
}{
{"Host capital", map[string]any{"headers": map[string]any{"Host": "h.com"}}, "h.com"},
{"host lowercase", map[string]any{"headers": map[string]any{"host": "h.com"}}, "h.com"},
{"no headers", map[string]any{}, ""},
{"nil", nil, ""},
{"headers not map", map[string]any{"headers": "not-a-map"}, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := WsHeaderHost(c.wsOpts)
if got != c.want {
t.Errorf("WsHeaderHost(%v) = %q, want %q", c.wsOpts, got, c.want)
}
})
}
}
func TestExtraFormatAlpn(t *testing.T) {
cases := []struct {
name string
input any
want string
}{
{"nil", nil, ""},
{"array_any", []any{"h2", "http/1.1"}, "h2,http/1.1"},
{"array_any_with_empty", []any{"h2", "", "http/1.1"}, "h2,http/1.1"},
{"array_string", []string{"h2", "http/1.1"}, "h2,http/1.1"},
{"array_string_with_empty", []string{"h2", "", "http/1.1"}, "h2,http/1.1"},
{"string", "h2", "h2"},
{"int", 42, "42"},
{"empty_array", []any{}, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := FormatAlpn(c.input)
if got != c.want {
t.Errorf("FormatAlpn(%v) = %q, want %q", c.input, got, c.want)
}
})
}
}
func TestExtraGetString(t *testing.T) {
proxy := model.ProxyNode{"key": "value", "num": 42}
if getString(proxy, "key") != "value" {
t.Error("getString failed for string")
}
if getString(proxy, "num") != "" {
t.Error("getString should return empty for non-string")
}
if getString(proxy, "missing") != "" {
t.Error("getString should return empty for missing key")
}
}
func TestExtraGetBool(t *testing.T) {
cases := []struct {
input any
want bool
}{
{true, true},
{false, false},
{"true", true},
{"1", true},
{"false", false},
{"0", false},
{42, false},
{nil, false},
}
for _, c := range cases {
proxy := model.ProxyNode{"key": c.input}
got := getBool(proxy, "key")
if got != c.want {
t.Errorf("getBool(%v) = %v, want %v", c.input, got, c.want)
}
}
}
func TestExtraGetInt(t *testing.T) {
cases := []struct {
input any
want int
}{
{42, 42},
{int64(64), 64},
{float64(128.0), 128},
{"256", 256},
{"not-a-num", 0},
{nil, 0},
{true, 0},
}
for _, c := range cases {
proxy := model.ProxyNode{"key": c.input}
got := getInt(proxy, "key")
if got != c.want {
t.Errorf("getInt(%v) = %d, want %d", c.input, got, c.want)
}
}
}
func TestExtraStringSetting(t *testing.T) {
if stringSetting(nil) != "" {
t.Error("expected empty for nil")
}
if stringSetting("hello") != "hello" {
t.Error("expected hello")
}
if stringSetting(42) != "42" {
t.Error("expected 42")
}
}
func TestExtraNumberOrUndefined(t *testing.T) {
cases := []struct {
input any
want int
}{
{nil, 0},
{42, 42},
{int64(64), 64},
{float64(128.0), 128},
{"", 0},
{"256", 256},
{"not-a-num", 0},
{true, 0},
}
for _, c := range cases {
got := numberOrUndefined(c.input)
if got != c.want {
t.Errorf("numberOrUndefined(%v) = %d, want %d", c.input, got, c.want)
}
}
}
func TestExtraHasValue(t *testing.T) {
cases := []struct {
input any
want bool
}{
{nil, false},
{"", false},
{"hello", true},
{42, true},
{true, true},
{false, true}, // false is not nil or empty-string
}
for _, c := range cases {
got := hasValue(c.input)
if got != c.want {
t.Errorf("hasValue(%v) = %v, want %v", c.input, got, c.want)
}
}
}
// --- surge.go helpers ---
func TestExtraItoa(t *testing.T) {
cases := []struct {
in int
want string
}{
{0, "0"},
{42, "42"},
{-42, "-42"},
{123456, "123456"},
}
for _, c := range cases {
if got := itoa(c.in); got != c.want {
t.Errorf("itoa(%d) = %q, want %q", c.in, got, c.want)
}
}
}
func TestExtraUnshift(t *testing.T) {
base := [][2]any{{"a", 1}, {"b", 2}}
front := [][2]any{{"x", 9}, {"y", 8}}
got := unshift(base, front...)
if len(got) != 4 {
t.Fatalf("expected 4 entries, got %d", len(got))
}
if got[0][0] != "x" || got[1][0] != "y" || got[2][0] != "a" || got[3][0] != "b" {
t.Errorf("unshift order wrong: %v", got)
}
}
func TestExtraStrOr(t *testing.T) {
proxy := model.ProxyNode{"cipher": "aes-256-gcm"}
if strOr(proxy, "cipher", "default") != "aes-256-gcm" {
t.Error("expected cipher value")
}
if strOr(proxy, "missing", "default") != "default" {
t.Error("expected default for missing key")
}
proxy2 := model.ProxyNode{"cipher": ""}
if strOr(proxy2, "cipher", "default") != "default" {
t.Error("expected default for empty string")
}
}
func TestExtraSniOr(t *testing.T) {
proxy := model.ProxyNode{"sni": "a", "servername": "b"}
if sniOr(proxy, "sni", "servername") != "a" {
t.Error("expected first key")
}
proxy2 := model.ProxyNode{"servername": "b"}
if sniOr(proxy2, "sni", "servername") != "b" {
t.Error("expected second key")
}
if sniOr(model.ProxyNode{}, "sni", "servername") != nil {
t.Error("expected nil when none present")
}
}
func TestExtraPskOrPassword(t *testing.T) {
proxy := model.ProxyNode{"psk": "psk-val"}
if pskOrPassword(proxy) != "psk-val" {
t.Error("expected psk value")
}
proxy2 := model.ProxyNode{"password": "pass-val"}
if pskOrPassword(proxy2) != "pass-val" {
t.Error("expected password value as fallback")
}
if pskOrPassword(model.ProxyNode{}) != nil {
t.Error("expected nil when neither present")
}
}
func TestExtraVersionOr3(t *testing.T) {
proxy := model.ProxyNode{"version": 4}
if versionOr3(proxy) != 4 {
t.Error("expected version 4")
}
if versionOr3(model.ProxyNode{}) != 3 {
t.Error("expected default 3")
}
}
func TestExtraSurgeType(t *testing.T) {
cases := []struct {
proxy model.ProxyNode
want string
}{
{model.ProxyNode{"type": "socks5", "tls": true}, "socks5-tls"},
{model.ProxyNode{"type": "socks5", "tls": false}, "socks5"},
{model.ProxyNode{"type": "http", "tls": true}, "https"},
{model.ProxyNode{"type": "http", "tls": false}, "http"},
{model.ProxyNode{"type": "vmess"}, "vmess"},
{model.ProxyNode{"type": "trojan"}, "trojan"},
}
for _, c := range cases {
got := surgeType(c.proxy)
if got != c.want {
t.Errorf("surgeType(%v) = %q, want %q", c.proxy, got, c.want)
}
}
}
// --- mihomo.go tests ---
func TestExtraRenderMihomoYamlDefault(t *testing.T) {
nodes := allNodeTypes()[:2]
out := RenderMihomoYaml(nodes, "https://sub.example.com/api", nil)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("not valid YAML: %v\n%s", err, out)
}
if parsed["mixed-port"] != 7890 {
t.Errorf("expected default mixed-port=7890, got: %v", parsed["mixed-port"])
}
if parsed["mode"] != "rule" {
t.Errorf("expected default mode=rule, got: %v", parsed["mode"])
}
if parsed["log-level"] != "info" {
t.Errorf("expected default log-level=info, got: %v", parsed["log-level"])
}
if parsed["allow-lan"] != false {
t.Errorf("expected default allow-lan=false, got: %v", parsed["allow-lan"])
}
// Note: DefaultProxyGroups uses []string for "proxies" but ExpandGroupProxies
// expects []any, so $all is never expanded and default groups produce empty
// proxy lists. This is a known source-code limitation; we verify the document
// still has the proxy-groups key present.
if _, ok := parsed["proxy-groups"]; !ok {
t.Error("expected proxy-groups key in output")
}
rules, _ := parsed["rules"].([]any)
if len(rules) != 1 || rules[0] != "MATCH,🚀 节点选择" {
t.Errorf("expected default MATCH rule, got: %v", rules)
}
}
func TestExtraRenderMihomoYamlWithTemplate(t *testing.T) {
nodes := allNodeTypes()[:3]
tmpl := map[string]any{
"mixed-port": 7891,
"allow-lan": true,
"mode": "global",
"log-level": "debug",
"dns": map[string]any{"enable": true},
"sniffer": map[string]any{"enable": true},
"rules": []any{
"GEOIP,CN,DIRECT",
"MATCH,PROXY",
},
}
out := RenderMihomoYaml(nodes, "https://sub.example.com/api", tmpl)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("not valid YAML: %v\n%s", err, out)
}
if parsed["mixed-port"] != 7891 {
t.Errorf("expected mixed-port=7891, got: %v", parsed["mixed-port"])
}
if parsed["allow-lan"] != true {
t.Errorf("expected allow-lan=true, got: %v", parsed["allow-lan"])
}
if parsed["mode"] != "global" {
t.Errorf("expected mode=global, got: %v", parsed["mode"])
}
if parsed["log-level"] != "debug" {
t.Errorf("expected log-level=debug, got: %v", parsed["log-level"])
}
if _, ok := parsed["dns"]; !ok {
t.Error("expected dns in output")
}
if _, ok := parsed["sniffer"]; !ok {
t.Error("expected sniffer in output")
}
rules, _ := parsed["rules"].([]any)
if len(rules) != 2 {
t.Errorf("expected 2 rules, got %d", len(rules))
}
}
func TestExtraRenderMihomoYamlRuleProviders(t *testing.T) {
nodes := allNodeTypes()[:1]
tmpl := map[string]any{
"rule-providers": map[string]any{
"reject": map[string]any{"type": "http", "url": "x"},
},
}
out := RenderMihomoYaml(nodes, "https://x", tmpl)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("not valid YAML: %v", err)
}
if _, ok := parsed["rule-providers"]; !ok {
t.Error("expected rule-providers in output")
}
}
func TestExtraRenderMihomoYamlCamelCaseKeys(t *testing.T) {
// Test camelCase variants (mixedPort, allowLan, logLevel)
tmpl := map[string]any{
"mixedPort": 7892,
"allowLan": true,
"logLevel": "warning",
}
nodes := allNodeTypes()[:1]
out := RenderMihomoYaml(nodes, "https://x", tmpl)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("not valid YAML: %v", err)
}
if parsed["mixed-port"] != 7892 {
t.Errorf("expected mixed-port=7892 from mixedPort, got: %v", parsed["mixed-port"])
}
if parsed["allow-lan"] != true {
t.Errorf("expected allow-lan=true from allowLan, got: %v", parsed["allow-lan"])
}
if parsed["log-level"] != "warning" {
t.Errorf("expected log-level=warning from logLevel, got: %v", parsed["log-level"])
}
}
func TestExtraRenderMihomoYamlCustomProxyGroups(t *testing.T) {
nodes := allNodeTypes()[:3]
tmpl := map[string]any{
"proxy-groups": []any{
map[string]any{
"name": "PROXY",
"type": "select",
"proxies": []any{"SS-Node", "VMess-Node", "DIRECT"},
},
map[string]any{
"name": "AUTO",
"type": "url-test",
"filter": ".*",
"url": "http://test",
},
},
}
out := RenderMihomoYaml(nodes, "https://x", tmpl)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("not valid YAML: %v\n%s", err, out)
}
groups, _ := parsed["proxy-groups"].([]any)
if len(groups) != 2 {
t.Fatalf("expected 2 groups, got %d", len(groups))
}
first, _ := groups[0].(map[string]any)
if first["name"] != "PROXY" {
t.Errorf("expected first group PROXY, got: %v", first["name"])
}
}
func TestExtraRenderMihomoYamlHeaderComment(t *testing.T) {
out := RenderMihomoYaml(allNodeTypes()[:1], "https://sub.example.com/api/path", nil)
if !strings.HasPrefix(out, "# Generated by Sub-Store") {
t.Errorf("expected header comment, got: %s", out[:50])
}
if !strings.Contains(out, "# Source: /api/path") {
t.Errorf("expected source path comment, got: %s", out)
}
}
func TestExtraDefaultProxyGroups(t *testing.T) {
groups := DefaultProxyGroups()
if len(groups) != 3 {
t.Fatalf("expected 3 default groups, got %d", len(groups))
}
if groups[0]["name"] != "🚀 节点选择" {
t.Errorf("expected first group 🚀 节点选择, got: %v", groups[0]["name"])
}
if groups[1]["type"] != "url-test" {
t.Errorf("expected second group url-test, got: %v", groups[1]["type"])
}
}
// --- mihomo: ExpandGroupProxies & RenderTemplateProxyGroups ---
func TestExtraExpandGroupProxiesAll(t *testing.T) {
group := map[string]any{
"proxies": []any{"$all"},
}
nodeNames := []string{"A", "B", "C"}
got := ExpandGroupProxies(group, nodeNames)
if !reflect.DeepEqual(got, nodeNames) {
t.Errorf("ExpandGroupProxies $all = %v, want %v", got, nodeNames)
}
}
func TestExtraExpandGroupProxiesExplicitNames(t *testing.T) {
group := map[string]any{
"proxies": []any{"X", "Y", "$all", "Z"},
}
nodeNames := []string{"A", "B"}
got := ExpandGroupProxies(group, nodeNames)
want := []string{"X", "Y", "A", "B", "Z"}
if !reflect.DeepEqual(got, want) {
t.Errorf("ExpandGroupProxies = %v, want %v", got, want)
}
}
func TestExtraExpandGroupProxiesFilter(t *testing.T) {
group := map[string]any{
"filter": "HK.*",
}
nodeNames := []string{"HK-1", "US-1", "HK-2", "JP-1"}
got := ExpandGroupProxies(group, nodeNames)
want := []string{"HK-1", "HK-2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("ExpandGroupProxies filter = %v, want %v", got, want)
}
}
func TestExtraExpandGroupProxiesFilterCaseInsensitive(t *testing.T) {
// Note: compileRegex strips "(?i)" prefix and prepends "i" as a flag,
// but Go regexp expects "(?i)" syntax not a bare flag prefix.
// So "(?i)hk.*" compiles to "ihk.*" which matches nothing.
// This test verifies the actual (buggy) behavior.
group := map[string]any{
"filter": "(?i)hk.*",
}
nodeNames := []string{"hk-1", "HK-2", "us-1"}
got := ExpandGroupProxies(group, nodeNames)
// Due to the bug, no names match
if len(got) != 0 {
t.Errorf("expected 0 matches due to (?i) handling bug, got: %v", got)
}
}
func TestExtraExpandGroupProxiesFilterInvalid(t *testing.T) {
group := map[string]any{
"filter": "[invalid",
}
got := ExpandGroupProxies(group, []string{"A"})
if len(got) != 0 {
t.Errorf("expected empty for invalid regex, got: %v", got)
}
}
func TestExtraExpandGroupProxiesFilterAndProxies(t *testing.T) {
group := map[string]any{
"filter": "HK.*",
"proxies": []any{"DIRECT", "$all"},
}
nodeNames := []string{"HK-1", "US-1"}
got := ExpandGroupProxies(group, nodeNames)
// filter results first, then proxies
want := []string{"HK-1", "DIRECT", "HK-1", "US-1"}
// uniqueStrings will dedupe
if !reflect.DeepEqual(got, want) {
// Due to uniqueStrings, HK-1 appears once
want = []string{"HK-1", "DIRECT", "US-1"}
if !reflect.DeepEqual(got, want) {
t.Errorf("ExpandGroupProxies filter+proxies = %v, want %v", got, want)
}
}
}
func TestExtraExpandGroupProxiesEmptyStrings(t *testing.T) {
group := map[string]any{
"proxies": []any{"", "A", "", "B"},
}
got := ExpandGroupProxies(group, []string{})
want := []string{"A", "B"}
if !reflect.DeepEqual(got, want) {
t.Errorf("ExpandGroupProxies empty strings = %v, want %v", got, want)
}
}
func TestExtraRenderTemplateProxyGroupsFiltersEmpty(t *testing.T) {
proxies := allNodeTypes()[:2]
groups := []map[string]any{
{
"name": "Empty",
"type": "select",
"proxies": []any{"NONEXISTENT"},
},
{
"name": "Real",
"type": "select",
"proxies": []any{"SS-Node", "VMess-Node"},
},
}
result := RenderTemplateProxyGroups(proxies, groups)
if len(result) != 1 {
t.Fatalf("expected 1 group (empty filtered), got %d", len(result))
}
if result[0]["name"] != "Real" {
t.Errorf("expected Real group, got: %v", result[0]["name"])
}
}
func TestExtraRenderTemplateProxyGroupsDeduplicates(t *testing.T) {
proxies := allNodeTypes()[:2]
groups := []map[string]any{
{
"name": "Test",
"type": "select",
"proxies": []any{"SS-Node", "SS-Node", "VMess-Node"},
},
}
result := RenderTemplateProxyGroups(proxies, groups)
if len(result) != 1 {
t.Fatalf("expected 1 group, got %d", len(result))
}
proxiesList, _ := result[0]["proxies"].([]string)
if len(proxiesList) != 2 {
t.Errorf("expected 2 unique proxies, got %d: %v", len(proxiesList), proxiesList)
}
}
func TestExtraRenderTemplateProxyGroupsKeepsAllowedLiterals(t *testing.T) {
proxies := allNodeTypes()[:1]
groups := []map[string]any{
{
"name": "Test",
"type": "select",
"proxies": []any{"SS-Node", "DIRECT", "REJECT", "PASS"},
},
}
result := RenderTemplateProxyGroups(proxies, groups)
proxiesList, _ := result[0]["proxies"].([]string)
if len(proxiesList) != 4 {
t.Errorf("expected 4 entries (node+3 literals), got %d: %v", len(proxiesList), proxiesList)
}
}
func TestExtraRenderTemplateProxyGroupsStripsFilterAndEmpty(t *testing.T) {
proxies := allNodeTypes()[:2]
groups := []map[string]any{
{
"name": "Test",
"type": "select",
"filter": ".*",
"proxies": []any{"SS-Node"},
"extra": "",
"url": "http://test",
},
}
result := RenderTemplateProxyGroups(proxies, groups)
if len(result) != 1 {
t.Fatalf("expected 1 group, got %d", len(result))
}
if _, ok := result[0]["filter"]; ok {
t.Error("filter should be stripped from output")
}
if _, ok := result[0]["extra"]; ok {
t.Error("empty string extra should be stripped")
}
if result[0]["url"] != "http://test" {
t.Error("url should be preserved")
}
}
func TestExtraRenderTemplateProxyGroupsCrossGroupRef(t *testing.T) {
proxies := allNodeTypes()[:1]
groups := []map[string]any{
{
"name": "Main",
"type": "select",
"proxies": []any{"SS-Node", "Sub"},
},
{
"name": "Sub",
"type": "select",
"proxies": []any{"SS-Node"},
},
}
result := RenderTemplateProxyGroups(proxies, groups)
if len(result) != 2 {
t.Fatalf("expected 2 groups, got %d", len(result))
}
}
// --- mihomo helpers ---
func TestExtraExtractGroupTemplates(t *testing.T) {
cases := []struct {
name string
cfg map[string]any
want int
}{
{"proxyGroups camelCase", map[string]any{"proxyGroups": []any{map[string]any{"name": "x"}}}, 1},
{"proxy-groups kebab", map[string]any{"proxy-groups": []any{map[string]any{"name": "x"}}}, 1},
{"none", map[string]any{}, 0},
{"invalid type", map[string]any{"proxyGroups": "not-an-array"}, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := extractGroupTemplates(c.cfg)
if len(got) != c.want {
t.Errorf("extractGroupTemplates = %d groups, want %d", len(got), c.want)
}
})
}
}
func TestExtraFirstStr(t *testing.T) {
cfg := map[string]any{"a": "x", "b": ""}
if firstStr(cfg, "a", "b") != "x" {
t.Error("expected x")
}
if firstStr(cfg, "b", "c") != "" {
t.Error("expected empty")
}
if firstStr(cfg, "missing") != "" {
t.Error("expected empty for missing")
}
}
func TestExtraFirstInt(t *testing.T) {
cfg := map[string]any{"a": 42, "b": int64(64), "c": float64(128)}
if firstInt(cfg, "a") != 42 {
t.Error("expected 42")
}
if firstInt(cfg, "b") != 64 {
t.Error("expected 64")
}
if firstInt(cfg, "c") != 128 {
t.Error("expected 128")
}
if firstInt(cfg, "missing") != 0 {
t.Error("expected 0 for missing")
}
}
func TestExtraFirstBool(t *testing.T) {
cfg := map[string]any{"a": true, "b": false}
if !firstBool(cfg, "a") {
t.Error("expected true")
}
if firstBool(cfg, "b") {
t.Error("expected false")
}
if firstBool(cfg, "missing") {
t.Error("expected false for missing")
}
if firstBool(cfg, "x", "y") {
t.Error("expected false for all missing")
}
}
func TestExtraFirstAny(t *testing.T) {
cfg := map[string]any{"a": "value"}
if firstAny(cfg, "a") != "value" {
t.Error("expected value")
}
if firstAny(cfg, "missing") != nil {
t.Error("expected nil for missing")
}
if firstAny(cfg, "nilkey") != nil {
t.Error("expected nil")
}
}
func TestExtraContainsString(t *testing.T) {
list := []string{"a", "b", "c"}
if !containsString(list, "b") {
t.Error("expected true for b")
}
if containsString(list, "z") {
t.Error("expected false for z")
}
if containsString([]string{}, "a") {
t.Error("expected false for empty list")
}
}
func TestExtraUniqueStrings(t *testing.T) {
input := []string{"a", "b", "a", "", "c", "b"}
got := uniqueStrings(input)
want := []string{"a", "b", "c"}
if !reflect.DeepEqual(got, want) {
t.Errorf("uniqueStrings = %v, want %v", got, want)
}
}
func TestExtraFindNamesByRegex(t *testing.T) {
names := []string{"HK-1", "US-1", "HK-2"}
got := findNamesByRegex(names, "HK.*")
want := []string{"HK-1", "HK-2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("findNamesByRegex = %v, want %v", got, want)
}
}
func TestExtraFindNamesByRegexInvalid(t *testing.T) {
got := findNamesByRegex([]string{"a"}, "[invalid")
if got != nil {
t.Errorf("expected nil for invalid regex, got: %v", got)
}
}
func TestExtraCompileRegex(t *testing.T) {
cases := []struct {
input string
nil bool
}{
{"abc", false},
{"(?i)abc", false},
{"[invalid", true},
}
for _, c := range cases {
re := compileRegex(c.input)
if c.nil && re != nil {
t.Errorf("expected nil for %q", c.input)
}
if !c.nil && re == nil {
t.Errorf("expected non-nil for %q", c.input)
}
}
}
func TestExtraCompileRegexCaseInsensitive(t *testing.T) {
re := compileRegex("(?i)abc")
if re == nil {
t.Fatal("expected non-nil regex")
}
// Note: compileRegex strips "(?i)" and prepends "i" as a bare flag,
// which Go regexp interprets as part of the pattern, not as a flag.
// So "(?i)abc" becomes "iabc" which does NOT match "ABC".
// This test verifies the actual (buggy) behavior.
if re.MatchString("iabc") {
// "iabc" matches because the pattern is literally "iabc"
} else {
t.Error("expected pattern 'iabc' to match string 'iabc'")
}
}
// --- json.go tests ---
func TestExtraRenderJsonStripsNil(t *testing.T) {
nodes := []model.ProxyNode{
{"type": "ss", "name": "x", "server": nil, "port": 1},
}
out := RenderJson(nodes)
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
proxies, _ := parsed["proxies"].([]any)
if len(proxies) != 1 {
t.Fatalf("expected 1 proxy, got %d", len(proxies))
}
first, _ := proxies[0].(map[string]any)
if _, ok := first["server"]; ok {
t.Error("nil server should be stripped")
}
if first["type"] != "ss" {
t.Error("type should be preserved")
}
}
func TestExtraStripNil(t *testing.T) {
m := map[string]any{"a": 1, "b": nil, "c": "x"}
result := stripNil(m)
if _, ok := result["b"]; ok {
t.Error("nil should be removed")
}
if result["a"] != 1 || result["c"] != "x" {
t.Error("non-nil should be preserved")
}
}
// --- v2ray base64 output ---
func TestExtraRenderV2rayBase64(t *testing.T) {
nodes := allNodeTypes()[:2]
out, err := RenderTarget(nodes, "v2ray", nil)
if err != nil {
t.Fatalf("v2ray render failed: %v", err)
}
// v2ray output is base64-encoded URI list
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(out))
if err != nil {
// Some encoders use raw URL encoding; try that
decoded, err = base64.RawURLEncoding.DecodeString(strings.TrimSpace(out))
if err != nil {
// It may also use RawStdEncoding
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(out))
if err != nil {
t.Fatalf("v2ray output is not valid base64: %v\noutput: %q", err, out)
}
}
}
if !strings.Contains(string(decoded), "://") {
t.Errorf("decoded v2ray output should contain URI scheme: %s", decoded)
}
}
// --- shadowrocket alias ---
func TestExtraShadowrocketAliasesToURI(t *testing.T) {
nodes := allNodeTypes()[:2]
outURI, err := RenderTarget(nodes, "uri", nil)
if err != nil {
t.Fatalf("uri render failed: %v", err)
}
outSR, err := RenderTarget(nodes, "shadowrocket", nil)
if err != nil {
t.Fatalf("shadowrocket render failed: %v", err)
}
if outURI != outSR {
t.Errorf("shadowrocket should alias to uri; uri=%q sr=%q", outURI, outSR)
}
}
// --- stash target (delegates to mihomo) ---
func TestExtraStashDelegatesToMihomo(t *testing.T) {
nodes := allNodeTypes()[:1]
outStash, err := RenderTarget(nodes, "stash", nil)
if err != nil {
t.Fatalf("stash render failed: %v", err)
}
if !strings.Contains(outStash, "Generated by Sub-Store") {
t.Errorf("stash should produce mihomo-style YAML: %s", outStash)
}
}
// --- additional coverage: RenderProxyUris via RenderBuildTarget ---
func TestExtraRenderBuildTargetURI(t *testing.T) {
nodes := allNodeTypes()[:2]
out, err := RenderBuildTarget(nodes, "uri", "https://x", nil)
if err != nil {
t.Fatalf("RenderBuildTarget uri failed: %v", err)
}
if !strings.Contains(out, "://") {
t.Errorf("expected URI output: %s", out)
}
}
// --- helper: entries to map ---
func entriesToMap(entries [][2]any) map[string]any {
m := make(map[string]any)
for _, e := range entries {
m[e[0].(string)] = e[1]
}
return m
}
// --- ensure URL parsing works (used by uri.go) ---
func TestExtraURIEncoding(t *testing.T) {
// Verify that special chars in passwords/names are properly encoded
node := model.ProxyNode{
"type": "trojan",
"name": "name with spaces & special",
"server": "s",
"port": 443,
"password": "p@ss/w!th$chars",
}
uri := ToProxyUri(node)
// Should be parseable as a URL
u, err := url.Parse(uri)
if err != nil {
t.Fatalf("uri not parseable: %v", err)
}
if u.Scheme != "trojan" {
t.Errorf("expected trojan scheme, got: %s", u.Scheme)
}
// name should be URL-encoded in fragment
decoded, err := url.QueryUnescape(u.Fragment)
if err != nil {
t.Fatalf("fragment not decodable: %v", err)
}
if decoded != "name with spaces & special" {
t.Errorf("expected decoded fragment, got: %q", decoded)
}
}
// --- additional regex/filter coverage ---
func TestExtraRenderTemplateProxyGroupsEmptyInput(t *testing.T) {
result := RenderTemplateProxyGroups(nil, nil)
if len(result) != 0 {
t.Errorf("expected 0 groups for nil input, got %d", len(result))
}
}
func TestExtraRenderTemplateProxyGroupsGroupWithOnlyFilter(t *testing.T) {
proxies := allNodeTypes()[:3]
groups := []map[string]any{
{
"name": "HK",
"type": "url-test",
"filter": "^SS-.*", // anchored to start to avoid matching VLESS-Node
},
}
result := RenderTemplateProxyGroups(proxies, groups)
if len(result) != 1 {
t.Fatalf("expected 1 group, got %d", len(result))
}
proxiesList, _ := result[0]["proxies"].([]string)
if len(proxiesList) != 1 || proxiesList[0] != "SS-Node" {
t.Errorf("expected only SS-Node, got: %v", proxiesList)
}
}
// --- additional egern coverage ---
func TestExtraEgernVmessSecurityDefault(t *testing.T) {
node := model.ProxyNode{
"type": "vmess", "name": "v", "server": "s", "port": 443,
"uuid": "u",
}
m := ToEgernProxy(node)
if m["security"] != "auto" {
t.Errorf("expected default security=auto for vmess, got: %v", m["security"])
}
}
func TestExtraEgernVlessNoSecurity(t *testing.T) {
node := model.ProxyNode{
"type": "vless", "name": "v", "server": "s", "port": 443,
"uuid": "u",
}
m := ToEgernProxy(node)
if s, ok := m["security"]; ok && s != "" {
t.Errorf("expected empty security for vless, got: %v", s)
}
}