mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-10 20:57:51 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -0,0 +1,176 @@
|
||||
-- converter_protocol_test.lua — 协议转换单元测试
|
||||
-- 用法:lua5.1 tests/converter_protocol_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local converter = require("substore.converter")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
local function eq(a, b)
|
||||
if type(a) ~= type(b) then return false end
|
||||
if type(a) == "table" then
|
||||
for k, v in pairs(a) do if not eq(v, b[k]) then return false end end
|
||||
for k in pairs(b) do if a[k] == nil then return false end end
|
||||
return true
|
||||
end
|
||||
return a == b
|
||||
end
|
||||
|
||||
-- ---------- 基础功能测试 ----------
|
||||
check("module exists", converter ~= nil)
|
||||
check("convert_protocol method exists", type(converter.convert_protocol) == "function")
|
||||
|
||||
-- ---------- vmess 转换测试 ----------
|
||||
local vmess_node = {
|
||||
proto = "vmess",
|
||||
server = "1.1.1.1",
|
||||
port = 443,
|
||||
uuid = "test-uuid-vmess",
|
||||
alterId = 0,
|
||||
cipher = "auto",
|
||||
network = "ws",
|
||||
security = "tls",
|
||||
sni = "example.com",
|
||||
path = "/ws",
|
||||
host = "example.com",
|
||||
}
|
||||
|
||||
-- vmess → vless
|
||||
local vless_converted = converter.convert_protocol(vmess_node, "vless")
|
||||
check("vmess to vless proto", vless_converted and vless_converted.proto == "vless")
|
||||
check("vmess to vless uuid", vless_converted and vless_converted.uuid == "test-uuid-vmess")
|
||||
check("vmess to vless server", vless_converted and vless_converted.server == "1.1.1.1")
|
||||
check("vmess to vless port", vless_converted and vless_converted.port == 443)
|
||||
check("vmess to vless network", vless_converted and vless_converted.network == "ws")
|
||||
check("vmess to vless security", vless_converted and vless_converted.security == "tls")
|
||||
check("vmess to vless sni", vless_converted and vless_converted.sni == "example.com")
|
||||
check("vmess to vless path", vless_converted and vless_converted.path == "/ws")
|
||||
check("vmess to vless host", vless_converted and vless_converted.host == "example.com")
|
||||
check("vmess to vless no alterId", vless_converted and vless_converted.alterId == nil)
|
||||
check("vmess to vless no cipher", vless_converted and vless_converted.cipher == nil)
|
||||
|
||||
-- vmess → trojan
|
||||
local trojan_converted = converter.convert_protocol(vmess_node, "trojan")
|
||||
check("vmess to trojan proto", trojan_converted and trojan_converted.proto == "trojan")
|
||||
check("vmess to trojan password", trojan_converted and trojan_converted.password == "test-uuid-vmess")
|
||||
check("vmess to trojan server", trojan_converted and trojan_converted.server == "1.1.1.1")
|
||||
check("vmess to trojan network", trojan_converted and trojan_converted.network == "ws")
|
||||
|
||||
-- vmess → shadowsocks (不支持)
|
||||
local ss_converted = converter.convert_protocol(vmess_node, "shadowsocks")
|
||||
check("vmess to ss unsupported", ss_converted == nil)
|
||||
|
||||
-- ---------- vless 转换测试 ----------
|
||||
local vless_node = {
|
||||
proto = "vless",
|
||||
server = "2.2.2.2",
|
||||
port = 8443,
|
||||
uuid = "test-uuid-vless",
|
||||
security = "tls",
|
||||
sni = "vless.example.com",
|
||||
network = "grpc",
|
||||
}
|
||||
|
||||
-- vless → vmess
|
||||
local vmess_from_vless = converter.convert_protocol(vless_node, "vmess")
|
||||
check("vless to vmess proto", vmess_from_vless and vmess_from_vless.proto == "vmess")
|
||||
check("vless to vmess uuid", vmess_from_vless and vmess_from_vless.uuid == "test-uuid-vless")
|
||||
check("vless to vmess server", vmess_from_vless and vmess_from_vless.server == "2.2.2.2")
|
||||
check("vless to vmess network", vmess_from_vless and vmess_from_vless.network == "grpc")
|
||||
|
||||
-- vless → trojan
|
||||
local trojan_from_vless = converter.convert_protocol(vless_node, "trojan")
|
||||
check("vless to trojan proto", trojan_from_vless and trojan_from_vless.proto == "trojan")
|
||||
check("vless to trojan password", trojan_from_vless and trojan_from_vless.password == "test-uuid-vless")
|
||||
check("vless to trojan sni", trojan_from_vless and trojan_from_vless.sni == "vless.example.com")
|
||||
|
||||
-- ---------- trojan 转换测试 ----------
|
||||
local trojan_node = {
|
||||
proto = "trojan",
|
||||
server = "3.3.3.3",
|
||||
port = 443,
|
||||
password = "trojan-pass",
|
||||
sni = "example.com",
|
||||
network = "tcp",
|
||||
}
|
||||
|
||||
-- trojan → vmess (password → uuid 生成)
|
||||
local vmess_from_trojan = converter.convert_protocol(trojan_node, "vmess")
|
||||
check("trojan to vmess proto", vmess_from_trojan and vmess_from_trojan.proto == "vmess")
|
||||
check("trojan to vmess uuid exists", vmess_from_trojan and vmess_from_trojan.uuid ~= nil)
|
||||
check("trojan to vmess uuid not password", vmess_from_trojan and vmess_from_trojan.uuid ~= "trojan-pass")
|
||||
check("trojan to vmess server", vmess_from_trojan and vmess_from_trojan.server == "3.3.3.3")
|
||||
|
||||
-- trojan → vless
|
||||
local vless_from_trojan = converter.convert_protocol(trojan_node, "vless")
|
||||
check("trojan to vless proto", vless_from_trojan and vless_from_trojan.proto == "vless")
|
||||
check("trojan to vless uuid exists", vless_from_trojan and vless_from_trojan.uuid ~= nil)
|
||||
check("trojan to vless sni", vless_from_trojan and vless_from_trojan.sni == "example.com")
|
||||
|
||||
-- ---------- shadowsocks 转换测试 (不支持) ----------
|
||||
local ss_node = {
|
||||
proto = "shadowsocks",
|
||||
server = "4.4.4.4",
|
||||
port = 8388,
|
||||
method = "aes-256-gcm",
|
||||
password = "ss-pass",
|
||||
}
|
||||
|
||||
local vmess_from_ss = converter.convert_protocol(ss_node, "vmess")
|
||||
check("ss to vmess unsupported", vmess_from_ss == nil)
|
||||
|
||||
local vless_from_ss = converter.convert_protocol(ss_node, "vless")
|
||||
check("ss to vless unsupported", vless_from_ss == nil)
|
||||
|
||||
-- ---------- 特殊字段转换测试 ----------
|
||||
local vmess_with_special = {
|
||||
proto = "vmess",
|
||||
server = "5.5.5.5",
|
||||
port = 443,
|
||||
uuid = "uuid-special",
|
||||
network = "h2",
|
||||
security = "reality",
|
||||
sni = "sni.example.com",
|
||||
alpn = "h2,http/1.1",
|
||||
fp = "chrome",
|
||||
headerType = "none",
|
||||
}
|
||||
|
||||
local vless_special = converter.convert_protocol(vmess_with_special, "vless")
|
||||
check("network h2 preserved", vless_special and vless_special.network == "h2")
|
||||
check("security reality preserved", vless_special and vless_special.security == "reality")
|
||||
check("sni preserved", vless_special and vless_special.sni == "sni.example.com")
|
||||
check("alpn preserved", vless_special and vless_special.alpn == "h2,http/1.1")
|
||||
check("fp preserved", vless_special and vless_special.fp == "chrome")
|
||||
|
||||
-- ---------- 字段缺失处理测试 ----------
|
||||
local vmess_minimal = {
|
||||
proto = "vmess",
|
||||
server = "6.6.6.6",
|
||||
port = 443,
|
||||
uuid = "uuid-minimal",
|
||||
}
|
||||
|
||||
local vless_minimal = converter.convert_protocol(vmess_minimal, "vless")
|
||||
check("minimal vmess to vless works", vless_minimal ~= nil)
|
||||
check("minimal server preserved", vless_minimal and vless_minimal.server == "6.6.6.6")
|
||||
check("minimal uuid preserved", vless_minimal and vless_minimal.uuid == "uuid-minimal")
|
||||
|
||||
-- ---------- 不支持的转换 ----------
|
||||
local result = converter.convert_protocol(vmess_node, "unsupported_proto")
|
||||
check("unsupported target returns nil", result == nil)
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,115 @@
|
||||
-- converter_template_test.lua — URL 模板渲染单元测试
|
||||
-- 用法:lua5.1 tests/converter_template_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local converter = require("substore.converter")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- 基础功能测试 ----------
|
||||
check("module exists", converter ~= nil)
|
||||
check("render_template method exists", type(converter.render_template) == "function")
|
||||
check("render_url_template method exists", type(converter.render_url_template) == "function")
|
||||
|
||||
-- ---------- 变量替换测试 ----------
|
||||
local node1 = { name = "TestNode", server = "1.1.1.1", port = 443, proto = "vmess", uuid = "abc-123", password = "pass" }
|
||||
check("render_template variable name", converter.render_template("{name}", node1) == "TestNode")
|
||||
check("render_template variable server", converter.render_template("{server}", node1) == "1.1.1.1")
|
||||
check("render_template variable port", converter.render_template("{port}", node1) == "443")
|
||||
check("render_template variable proto", converter.render_template("{proto}", node1) == "vmess")
|
||||
check("render_template variable uuid", converter.render_template("{uuid}", node1) == "abc-123")
|
||||
check("render_template variable password", converter.render_template("{password}", node1) == "pass")
|
||||
check("render_template multiple vars", converter.render_template("{name}@{server}:{port}", node1) == "TestNode@1.1.1.1:443")
|
||||
|
||||
-- ---------- 函数调用测试 ----------
|
||||
check("render_template upper", converter.render_template("{upper:name}", node1) == "TESTNODE")
|
||||
check("render_template lower", converter.render_template("{lower:server}", node1) == "1.1.1.1")
|
||||
check("render_template upper server", converter.render_template("{upper:server}", node1) == "1.1.1.1")
|
||||
check("render_template lower proto", converter.render_template("{lower:proto}", node1) == "vmess")
|
||||
|
||||
-- ---------- 默认值测试 ----------
|
||||
check("render_template default missing", converter.render_template("{name|DefaultName}", {}) == "DefaultName")
|
||||
check("render_template default present", converter.render_template("{name|DefaultName}", node1) == "TestNode")
|
||||
check("render_template default empty", converter.render_template("{missing|fallback}", node1) == "fallback")
|
||||
|
||||
-- ---------- 条件渲染测试 ----------
|
||||
check("render_template conditional true", converter.render_template("{if:proto?vmess:vless}", node1) == "vmess")
|
||||
check("render_template conditional false", converter.render_template("{if:missing?yes:no}", node1) == "no")
|
||||
check("render_template conditional with value", converter.render_template("{if:server?has-server:no-server}", node1) == "has-server")
|
||||
|
||||
-- ---------- 批量渲染测试 ----------
|
||||
local nodes = {
|
||||
{ name = "Node1", server = "1.1.1.1", port = 443 },
|
||||
{ name = "Node2", server = "2.2.2.2", port = 8443 },
|
||||
}
|
||||
local urls = converter.render_url_template(nodes, "{name}-{server}:{port}")
|
||||
check("render_url_template count", type(urls) == "table" and #urls == 2)
|
||||
check("render_url_template first", urls[1] == "Node1-1.1.1.1:443")
|
||||
check("render_url_template second", urls[2] == "Node2-2.2.2.2:8443")
|
||||
|
||||
-- ---------- 协议 URL 模板测试 ----------
|
||||
local vmess_node = {
|
||||
name = "VMessTest",
|
||||
server = "1.1.1.1",
|
||||
port = 443,
|
||||
uuid = "uuid-vmess",
|
||||
security = "tls",
|
||||
}
|
||||
local vless_node = {
|
||||
name = "VLESS Test",
|
||||
server = "2.2.2.2",
|
||||
port = 8443,
|
||||
uuid = "uuid-vless",
|
||||
}
|
||||
local trojan_node = {
|
||||
name = "TrojanTest",
|
||||
server = "3.3.3.3",
|
||||
port = 443,
|
||||
password = "trojan-pass",
|
||||
}
|
||||
local ss_node = {
|
||||
name = "SSTest",
|
||||
server = "4.4.4.4",
|
||||
port = 8388,
|
||||
method = "aes-256-gcm",
|
||||
password = "ss-pass",
|
||||
}
|
||||
|
||||
-- 检查模板常量存在
|
||||
check("vmess template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.vmess ~= nil)
|
||||
check("vless template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.vless ~= nil)
|
||||
check("trojan template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.trojan ~= nil)
|
||||
check("ss template exists", converter.URL_TEMPLATES and converter.URL_TEMPLATES.ss ~= nil)
|
||||
|
||||
-- 使用模板渲染
|
||||
local vmess_url = converter.render_template(converter.URL_TEMPLATES.vmess, vmess_node)
|
||||
check("vmess url contains uuid", vmess_url:find("uuid-vmess", 1, true) ~= nil)
|
||||
check("vmess url contains server", vmess_url:find("1.1.1.1") ~= nil)
|
||||
check("vmess url contains name", vmess_url:find("VMessTest") ~= nil)
|
||||
|
||||
local vless_url = converter.render_template(converter.URL_TEMPLATES.vless, vless_node)
|
||||
check("vless url contains uuid", vless_url:find("uuid-vless", 1, true) ~= nil)
|
||||
check("vless url contains server", vless_url:find("2.2.2.2") ~= nil)
|
||||
|
||||
local trojan_url = converter.render_template(converter.URL_TEMPLATES.trojan, trojan_node)
|
||||
check("trojan url contains password", trojan_url:find("trojan-pass", 1, true) ~= nil)
|
||||
check("trojan url contains server", trojan_url:find("3.3.3.3") ~= nil)
|
||||
|
||||
local ss_url = converter.render_template(converter.URL_TEMPLATES.ss, ss_node)
|
||||
check("ss url contains method", ss_url:find("aes-256-gcm", 1, true) ~= nil)
|
||||
check("ss url contains password", ss_url:find("ss-pass", 1, true) ~= nil)
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,81 @@
|
||||
-- converter_test.lua — 转换引擎单元测试
|
||||
-- 用法:lua5.1 tests/converter_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local converter = require("substore.converter")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- 基础功能测试 ----------
|
||||
check("module exists", converter ~= nil)
|
||||
check("validate_nodes method exists", type(converter.validate_nodes) == "function")
|
||||
check("convert method exists", type(converter.convert) == "function")
|
||||
check("process_pipeline method exists", type(converter.process_pipeline) == "function")
|
||||
check("apply_template method exists", type(converter.apply_template) == "function")
|
||||
|
||||
-- ---------- validate_nodes 测试 ----------
|
||||
local valid_nodes = {
|
||||
{ proto = "vmess", server = "1.1.1.1", port = 443, name = "Node1" },
|
||||
{ proto = "vless", server = "2.2.2.2", port = 8443, name = "Node2" },
|
||||
}
|
||||
local ok, errs = converter.validate_nodes(valid_nodes)
|
||||
check("validate_nodes valid", ok == true and (errs == nil or #errs == 0))
|
||||
|
||||
local invalid_nodes = {
|
||||
{ proto = "vmess", server = "1.1.1.1", port = nil },
|
||||
{ proto = nil, server = "2.2.2.2", port = 443 },
|
||||
}
|
||||
local ok2, errs2 = converter.validate_nodes(invalid_nodes)
|
||||
check("validate_nodes invalid", ok2 == false and errs2 ~= nil and #errs2 > 0)
|
||||
|
||||
-- ---------- convert 测试 ----------
|
||||
local nodes_for_output = {
|
||||
{ proto = "vmess", name = "TestVMess", server = "1.1.1.1", port = 443, uuid = "uuid-1" },
|
||||
{ proto = "trojan", name = "TestTrojan", server = "2.2.2.2", port = 443, password = "pass" },
|
||||
}
|
||||
local yaml_out = converter.convert(nodes_for_output, "clash", {})
|
||||
check("convert clash returns string", type(yaml_out) == "string" and yaml_out:find("proxies:") ~= nil)
|
||||
check("convert clash contains node name", yaml_out and yaml_out:find("TestVMess") ~= nil)
|
||||
|
||||
local json_out = converter.convert(nodes_for_output, "json", {})
|
||||
check("convert json returns string", type(json_out) == "string" and json_out:find("TestVMess") ~= nil)
|
||||
|
||||
local b64_out = converter.convert(nodes_for_output, "base64", {})
|
||||
check("convert base64 returns string", type(b64_out) == "string" and #b64_out > 0)
|
||||
|
||||
-- ---------- process_pipeline 测试 ----------
|
||||
local pipeline_nodes = {
|
||||
{ proto = "vmess", name = "HK VMESS", server = "1.1.1.1", port = 443 },
|
||||
{ proto = "trojan", name = "US TROJAN", server = "2.2.2.2", port = 443 },
|
||||
{ proto = "vmess", name = "JP VMESS", server = "3.3.3.3", port = 443 },
|
||||
}
|
||||
local pipeline = {
|
||||
{ type = "filter", opts = { proto = "vmess" } },
|
||||
}
|
||||
local filtered = converter.process_pipeline(pipeline_nodes, pipeline)
|
||||
check("process_pipeline filter count", filtered ~= nil and #filtered == 2)
|
||||
check("process_pipeline filter proto", filtered and filtered[1].proto == "vmess" and filtered[2].proto == "vmess")
|
||||
|
||||
-- ---------- apply_template 测试 ----------
|
||||
local template_nodes = {
|
||||
{ proto = "vmess", name = "NodeA", server = "1.1.1.1", port = 443 },
|
||||
}
|
||||
local template = "{{name}}-{{proto}}"
|
||||
local templated = converter.apply_template(template_nodes, template)
|
||||
check("apply_template returns table", type(templated) == "table")
|
||||
check("apply_template preserves count", templated ~= nil and #templated == 1)
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,106 @@
|
||||
-- core_combo_test.lua — 组合订阅(选择性合并 + 组合订阅链接)单元测试
|
||||
-- 用法:lua5.1 tests/core_combo_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local core = require("substore.core")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- 重定向数据目录到临时目录,避免污染系统
|
||||
local tmp = os.tmpname() .. "_substore"
|
||||
os.execute("mkdir -p " .. string.format("%q", tmp .. "/nodes"))
|
||||
core.DATA_DIR = tmp
|
||||
core.LIST_FILE = tmp .. "/subscriptions.json"
|
||||
core.NODES_DIR = tmp .. "/nodes"
|
||||
|
||||
check("add_combo exists", type(core.add_combo) == "function")
|
||||
check("combo_refresh exists", type(core.combo_refresh) == "function")
|
||||
check("refresh_combos exists", type(core.refresh_combos) == "function")
|
||||
check("save_combo exists", type(core.save_combo) == "function")
|
||||
|
||||
-- 建两个源订阅
|
||||
local a = core.add("订阅A", "http://example.com/a")
|
||||
local b = core.add("订阅B", "http://example.com/b")
|
||||
check("source A added", a ~= nil)
|
||||
check("source B added", b ~= nil)
|
||||
|
||||
core.write_nodes(a, {
|
||||
{ proto = "vmess", name = "A-香港-01", server = "1.1.1.1", port = 443, uuid = "u1" },
|
||||
{ proto = "vmess", name = "A-美国-01", server = "2.2.2.2", port = 443, uuid = "u2" },
|
||||
})
|
||||
core.write_nodes(b, {
|
||||
{ proto = "shadowsocks", name = "B-日本-01", server = "3.3.3.3", port = 8388, method = "aes-256-gcm", password = "p" },
|
||||
})
|
||||
|
||||
-- 创建组合:合并 A+B
|
||||
local c = core.add_combo("我的组合", { a, b })
|
||||
check("add_combo returns id", c ~= nil)
|
||||
|
||||
local cmeta = core.get(c)
|
||||
check("combo flag set", cmeta.combo == true)
|
||||
check("combo has 2 sources", type(cmeta.sources) == "table" and #cmeta.sources == 2)
|
||||
check("combo node_count = 3", cmeta.node_count == 3)
|
||||
|
||||
local cnodes = core.read_nodes(c)
|
||||
check("combo read_nodes = 3", #cnodes == 3)
|
||||
|
||||
-- 组合 token → 生成订阅链接,含两源节点
|
||||
local token = core.ensure_token(c)
|
||||
local yaml, ct, filename = core.generate_link(token, "ClashMeta")
|
||||
check("combo generate_link ok", type(yaml) == "string" and yaml ~= "")
|
||||
check("combo clashmeta contains A node", yaml and yaml:find("A-香港-01", 1, true) ~= nil)
|
||||
check("combo clashmeta contains B node", yaml and yaml:find("B-日本-01", 1, true) ~= nil)
|
||||
|
||||
-- 关键词规则:只保留香港
|
||||
local c2 = core.add_combo("香港组合", { a }, { rules_enable = "1", keyword_include = "香港" })
|
||||
check("combo with keyword include node_count = 1", core.get(c2).node_count == 1)
|
||||
|
||||
-- 去重:A 与 B 含同 server:port 节点
|
||||
core.write_nodes(b, {
|
||||
{ proto = "vmess", name = "A-香港-01", server = "1.1.1.1", port = 443, uuid = "u1" },
|
||||
})
|
||||
core.refresh_combos(a)
|
||||
core.refresh_combos(b)
|
||||
local c3 = core.add_combo("去重组合", { a, b }, { rules_enable = "1", dedup = "1" })
|
||||
check("combo with dedup removes dup", core.get(c3).node_count == 2)
|
||||
|
||||
-- 陈旧性:源 A 追加节点后,refresh_combos 使组合跟随更新
|
||||
core.write_nodes(a, {
|
||||
{ proto = "vmess", name = "A-香港-01", server = "1.1.1.1", port = 443, uuid = "u1" },
|
||||
{ proto = "vmess", name = "A-美国-01", server = "2.2.2.2", port = 443, uuid = "u2" },
|
||||
{ proto = "vmess", name = "A-新增-01", server = "4.4.4.4", port = 443, uuid = "u3" },
|
||||
})
|
||||
core.refresh_combos(a)
|
||||
check("combo follows source refresh", core.get(c).node_count == 4)
|
||||
|
||||
-- 编辑组合:改名称、改来源为仅 B
|
||||
local cnt = core.save_combo(c, "改名组合", { b }, {})
|
||||
check("save_combo returns count", cnt == 1)
|
||||
check("combo renamed", core.get(c).name == "改名组合")
|
||||
check("combo sources updated", #core.get(c).sources == 1 and core.get(c).sources[1] == b)
|
||||
|
||||
-- 空 sources 报错
|
||||
local e, eerr = core.add_combo("空组合", {})
|
||||
check("empty sources returns nil", e == nil)
|
||||
check("empty sources err", eerr ~= nil)
|
||||
|
||||
-- 非法源 id 被忽略 → 视为空,报错
|
||||
local e2 = core.add_combo("坏源", { "not-a-valid-id!!!" })
|
||||
check("invalid source returns nil", e2 == nil)
|
||||
|
||||
-- 清理
|
||||
os.execute("rm -rf " .. string.format("%q", tmp))
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,63 @@
|
||||
-- core_cron_test.lua — 按订阅定时更新(cron)单元测试
|
||||
-- 用法:lua5.1 tests/core_cron_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
local function check(name, cond)
|
||||
if cond then passed = passed + 1; print("PASS " .. name)
|
||||
else failed = failed + 1; print("FAIL " .. name) end
|
||||
end
|
||||
|
||||
-- 重定向数据目录与 cron 文件到临时目录,避免污染系统
|
||||
local tmp = os.tmpname() .. "_substore_cron"
|
||||
os.execute("mkdir -p " .. string.format("%q", tmp .. "/nodes"))
|
||||
core.DATA_DIR = tmp
|
||||
core.LIST_FILE = tmp .. "/subscriptions.json"
|
||||
core.NODES_DIR = tmp .. "/nodes"
|
||||
core.CRON_FILE = tmp .. "/cron.d.substore"
|
||||
|
||||
-- ---------- cron_time_valid ----------
|
||||
check("valid cron", core.cron_time_valid("0 3 * * *"))
|
||||
check("valid all wildcard", core.cron_time_valid("* * * * *"))
|
||||
check("valid single digit", core.cron_time_valid("30 4 1 12 6"))
|
||||
check("invalid too many fields", not core.cron_time_valid("0 3 * * * extra"))
|
||||
check("invalid too few fields", not core.cron_time_valid("0 3 * *"))
|
||||
check("invalid empty", not core.cron_time_valid(""))
|
||||
check("invalid injection semicolon", not core.cron_time_valid("0 3 * * *;rm -rf /"))
|
||||
check("invalid injection pipe", not core.cron_time_valid("0 3 * * * | reboot"))
|
||||
check("invalid non-digit", not core.cron_time_valid("0 3 * * abc"))
|
||||
|
||||
-- ---------- add 带 cron ----------
|
||||
local id1 = core.add("带定时", "http://example.com/a", { cron_enable = "1", cron_time = "0 4 * * *" })
|
||||
local m1 = core.get(id1)
|
||||
check("add with cron enabled", m1.cron_enable == true and m1.cron_time == "0 4 * * *")
|
||||
|
||||
local id2 = core.add("无定时", "http://example.com/b")
|
||||
local m2 = core.get(id2)
|
||||
check("add default cron disabled", m2.cron_enable ~= true and m2.cron_time == "")
|
||||
|
||||
local id3 = core.add("无效定时", "http://example.com/c", { cron_enable = "1", cron_time = "0 0 * * ;evil" })
|
||||
local m3 = core.get(id3)
|
||||
check("add invalid cron sanitized", m3.cron_enable ~= true and m3.cron_time == "")
|
||||
|
||||
-- ---------- write_cron ----------
|
||||
core.write_cron()
|
||||
local raw = util.read_file(core.CRON_FILE) or ""
|
||||
check("cron file has enabled sub", raw:find(id1, 1, true) ~= nil)
|
||||
check("cron file excludes disabled", raw:find(id2, 1, true) == nil)
|
||||
check("cron file has header comment", raw:find("# luci-app-substore cron", 1, true) ~= nil)
|
||||
|
||||
-- 全部关闭后:cron 文件应被移除
|
||||
core.save_meta(id1, { cron_enable = "0" })
|
||||
core.write_cron()
|
||||
check("cron file removed when none enabled", util.read_file(core.CRON_FILE) == nil)
|
||||
|
||||
-- 清理
|
||||
os.execute("rm -rf " .. string.format("%q", tmp))
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,83 @@
|
||||
-- core_link_test.lua — 订阅链接生成(core.generate_link)单元测试
|
||||
-- 用法:lua5.1 tests/core_link_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- 重定向数据目录到临时目录,避免污染系统
|
||||
local tmp = os.tmpname() .. "_substore"
|
||||
os.execute("mkdir -p " .. string.format("%q", tmp .. "/nodes"))
|
||||
core.DATA_DIR = tmp
|
||||
core.LIST_FILE = tmp .. "/subscriptions.json"
|
||||
core.NODES_DIR = tmp .. "/nodes"
|
||||
|
||||
check("module exists", core ~= nil)
|
||||
check("generate_link exists", type(core.generate_link) == "function")
|
||||
check("ensure_token exists", type(core.ensure_token) == "function")
|
||||
|
||||
-- 创建订阅并写入节点
|
||||
local id, aerr = core.add("测试订阅", "http://example.com/sub")
|
||||
check("add subscription", id ~= nil, aerr or "")
|
||||
|
||||
local token = core.ensure_token(id)
|
||||
check("token generated", type(token) == "string" and #token == 16)
|
||||
check("token idempotent", core.ensure_token(id) == token)
|
||||
|
||||
local nodes = {
|
||||
{ proto = "vmess", name = "NodeA", server = "1.1.1.1", port = 443, uuid = "u1", net = "tcp" },
|
||||
{ proto = "shadowsocks", name = "NodeB", server = "2.2.2.2", port = 8388, method = "aes-256-gcm", password = "p1" },
|
||||
}
|
||||
check("write nodes", core.write_nodes(id, nodes) == true)
|
||||
|
||||
-- 各格式生成
|
||||
local ok = true
|
||||
for _, target in ipairs({ "ClashMeta", "singbox", "v2ray", "Surge", "QX", "Shadowrocket", "V2RayURI", "Plain", "Stash", "Loon", "Egern", "Surfboard", "SurgeMac" }) do
|
||||
local content, ct, filename, err = core.generate_link(token, target)
|
||||
if type(content) == "string" and content ~= "" and ct and filename then
|
||||
-- ok
|
||||
else
|
||||
ok = false
|
||||
print(" fail:", target, tostring(err))
|
||||
end
|
||||
end
|
||||
check("all 13 targets generate", ok)
|
||||
|
||||
local yaml, ct, filename = core.generate_link(token, "ClashMeta")
|
||||
check("clashmeta has proxies", yaml and yaml:find("proxies:") ~= nil)
|
||||
check("clashmeta content-type", ct == "text/plain; charset=utf-8")
|
||||
check("clashmeta filename .yaml", filename and filename:match("%.yaml$") ~= nil)
|
||||
|
||||
local sb = core.generate_link(token, "singbox")
|
||||
check("singbox is json", sb and sb:sub(1, 1) == "{")
|
||||
|
||||
-- 错误 token
|
||||
local bad, _, _, err = core.generate_link("deadbeef", "ClashMeta")
|
||||
check("bad token returns nil", bad == nil)
|
||||
check("bad token has err", err ~= nil)
|
||||
|
||||
-- 无节点订阅
|
||||
local id2 = core.add("空订阅", "http://example.com/empty")
|
||||
local token2 = core.ensure_token(id2)
|
||||
local empty, _, _, err2 = core.generate_link(token2, "ClashMeta")
|
||||
check("empty nodes returns nil", empty == nil)
|
||||
check("empty nodes err", err2 ~= nil)
|
||||
|
||||
-- 清理
|
||||
os.execute("rm -rf " .. string.format("%q", tmp))
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,57 @@
|
||||
-- core.merge_form_node 测试:表单字段整体替换、非表单字段保留
|
||||
package.path = "root/usr/share/?.lua;root/usr/share/?/init.lua;" .. package.path
|
||||
|
||||
-- core.lua 的 merge_form_node 不依赖 io/网络,可直接测试
|
||||
local core = require("substore.core")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
local function check(name, cond)
|
||||
if cond then passed = passed + 1; print("PASS " .. name)
|
||||
else failed = failed + 1; print("FAIL " .. name) end
|
||||
end
|
||||
|
||||
local orig = {
|
||||
proto = "vmess", name = "old", group = "g1",
|
||||
server = "a.com", port = 443, uuid = "u1", sni = "a.com",
|
||||
raw = "vmess://xxx", tags = { "fast" }, remark_src = "机场A",
|
||||
}
|
||||
|
||||
-- 表单提交的新值:改名、换分组、清空 sni、改端口
|
||||
local formnode = {
|
||||
proto = "vmess", name = "new", group = "g2",
|
||||
server = "a.com", port = 8443, uuid = "u1",
|
||||
}
|
||||
|
||||
local m = core.merge_form_node(orig, formnode)
|
||||
check("name replaced", m.name == "new")
|
||||
check("group replaced", m.group == "g2")
|
||||
check("port replaced", m.port == 8443)
|
||||
check("server kept", m.server == "a.com")
|
||||
check("sni cleared (form-managed, absent in form)", m.sni == nil)
|
||||
check("raw preserved", m.raw == "vmess://xxx")
|
||||
check("tags preserved", m.tags and m.tags[1] == "fast")
|
||||
check("custom field preserved", m.remark_src == "机场A")
|
||||
check("proto kept", m.proto == "vmess")
|
||||
check("no type key", m.type == nil)
|
||||
|
||||
-- 分组清空:formnode 无 group 键 → 清除
|
||||
local m2 = core.merge_form_node(orig, { proto = "vmess", name = "n", server = "a.com", port = 1 })
|
||||
check("group cleared when absent", m2.group == nil)
|
||||
|
||||
-- 协议切换:旧协议字段被清除
|
||||
local m3 = core.merge_form_node(orig, {
|
||||
proto = "trojan", name = "t", server = "a.com", port = 443, password = "pw",
|
||||
})
|
||||
check("proto switched", m3.proto == "trojan")
|
||||
check("uuid cleared after proto switch", m3.uuid == nil)
|
||||
check("password set", m3.password == "pw")
|
||||
|
||||
-- 空表/空原节点容错
|
||||
local m4 = core.merge_form_node(nil, { name = "x" })
|
||||
check("nil orig ok", m4.name == "x")
|
||||
local m5 = core.merge_form_node(orig, nil)
|
||||
check("nil form keeps non-form fields", m5.raw == "vmess://xxx")
|
||||
check("nil form clears form fields", m5.name == nil)
|
||||
|
||||
print(string.format("%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,82 @@
|
||||
-- core_rules_test.lua — 订阅级规则单元测试
|
||||
-- 用法:lua5.1 tests/core_rules_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
local function check(name, cond)
|
||||
if cond then passed = passed + 1; print("PASS " .. name)
|
||||
else failed = failed + 1; print("FAIL " .. name) end
|
||||
end
|
||||
|
||||
-- 重定向数据目录与 cron 文件到临时目录,避免污染系统
|
||||
local tmp = os.tmpname() .. "_substore_rules"
|
||||
os.execute("mkdir -p " .. string.format("%q", tmp .. "/nodes"))
|
||||
core.DATA_DIR = tmp
|
||||
core.LIST_FILE = tmp .. "/subscriptions.json"
|
||||
core.NODES_DIR = tmp .. "/nodes"
|
||||
core.CRON_FILE = tmp .. "/cron.d.substore"
|
||||
|
||||
local nodes = {
|
||||
{ name = "HK-vmess", proto = "vmess", server = "1.1.1.1", port = 443 },
|
||||
{ name = "US-vless", proto = "vless", server = "2.2.2.2", port = 443 },
|
||||
{ name = "HK-trojan", proto = "trojan", server = "3.3.3.3", port = 443 },
|
||||
{ name = "HK-vmess-dup", proto = "vmess", server = "1.1.1.1", port = 443 },
|
||||
}
|
||||
|
||||
-- ---------- add 存储规则字段 ----------
|
||||
local id = core.add("规则订阅", "http://example.com/r", {
|
||||
rules_enable = "1", proto_filter = "vmess,vless",
|
||||
keyword_include = "HK", keyword_exclude = "US",
|
||||
dedup = "1", rename_map = "HK-vmess=香港节点",
|
||||
})
|
||||
local m = core.get(id)
|
||||
check("add stores rules_enable", m.rules_enable == true)
|
||||
check("add stores proto_filter", m.proto_filter == "vmess,vless")
|
||||
check("add stores keyword_include", m.keyword_include == "HK")
|
||||
check("add stores dedup", m.dedup == "1")
|
||||
|
||||
-- ---------- apply_rules 禁用时不变 ----------
|
||||
local r0 = core.apply_rules(nodes, { rules_enable = "0" })
|
||||
check("disabled returns unchanged", #r0 == #nodes)
|
||||
|
||||
-- ---------- apply_rules 协议过滤 ----------
|
||||
local r1 = core.apply_rules(nodes, { rules_enable = "1", proto_filter = "vmess" })
|
||||
check("proto_filter keeps only vmess", #r1 == 2)
|
||||
|
||||
-- ---------- 关键词包含 / 排除 ----------
|
||||
local r2 = core.apply_rules(nodes, { rules_enable = "1", keyword_include = "HK" })
|
||||
check("keyword_include HK", #r2 == 3)
|
||||
local r3 = core.apply_rules(nodes, { rules_enable = "1", keyword_exclude = "US" })
|
||||
check("keyword_exclude US", #r3 == 3)
|
||||
|
||||
-- ---------- 多关键词(逗号分隔)----------
|
||||
local rm1 = core.apply_rules(nodes, { rules_enable = "1", keyword_include = "HK,trojan" })
|
||||
check("multi keyword_include HK,trojan", #rm1 == 3)
|
||||
local rm2 = core.apply_rules(nodes, { rules_enable = "1", keyword_exclude = "US,vless" })
|
||||
check("multi keyword_exclude US,vless", #rm2 == 3)
|
||||
local rm3 = core.apply_rules(nodes, { rules_enable = "1", keyword_include = "HK,trojan" })
|
||||
check("multi keyword Chinese comma", #rm3 == 3)
|
||||
|
||||
-- ---------- 去重 ----------
|
||||
local r4 = core.apply_rules(nodes, { rules_enable = "1", dedup = "1" })
|
||||
check("dedup removes duplicate", #r4 == 3)
|
||||
|
||||
-- ---------- 重命名 ----------
|
||||
local r5 = core.apply_rules(nodes, { rules_enable = "1", rename_map = "HK-vmess=香港节点" })
|
||||
local renamed = false
|
||||
for _, n in ipairs(r5) do if n.name == "香港节点" then renamed = true end end
|
||||
check("rename_map applies", renamed)
|
||||
|
||||
-- ---------- 组合规则 ----------
|
||||
local r6 = core.apply_rules(nodes, { rules_enable = "1", proto_filter = "vmess", dedup = "1", keyword_include = "HK" })
|
||||
check("combined filter+dedup+keyword", #r6 == 1)
|
||||
|
||||
-- 清理
|
||||
os.execute("rm -rf " .. string.format("%q", tmp))
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,45 @@
|
||||
-- core_userinfo_test.lua — subscription-userinfo 解析与人性化格式单元测试
|
||||
-- 用法:lua5.1 tests/core_userinfo_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
local function check(name, cond)
|
||||
if cond then passed = passed + 1; print("PASS " .. name)
|
||||
else failed = failed + 1; print("FAIL " .. name) end
|
||||
end
|
||||
|
||||
-- ---------- parse_userinfo ----------
|
||||
local u = core.parse_userinfo("upload=1024; download=2048; total=107374182400; expire=1757951999")
|
||||
check("userinfo upload", u and u.upload == 1024)
|
||||
check("userinfo download", u and u.download == 2048)
|
||||
check("userinfo total", u and u.total == 107374182400)
|
||||
check("userinfo expire", u and u.expire == 1757951999)
|
||||
|
||||
check("userinfo trim spaces", core.parse_userinfo("upload = 1 ; download=2") ~= nil)
|
||||
check("userinfo empty nil", core.parse_userinfo("") == nil)
|
||||
check("userinfo nonstring nil", core.parse_userinfo(nil) == nil)
|
||||
check("userinfo no fields nil", core.parse_userinfo("foo=bar") == nil)
|
||||
local u2 = core.parse_userinfo("total=100")
|
||||
check("userinfo partial", u2 ~= nil and u2.total == 100 and u2.upload == nil)
|
||||
|
||||
-- ---------- human_bytes ----------
|
||||
check("human_bytes zero", util.human_bytes(0) == "0B")
|
||||
check("human_bytes 20G", util.human_bytes(20 * 1024 * 1024 * 1024) == "20G")
|
||||
check("human_bytes 512M", util.human_bytes(512 * 1024 * 1024) == "512M")
|
||||
check("human_bytes 1.5G", util.human_bytes(1.5 * 1024 * 1024 * 1024) == "1.5G")
|
||||
check("human_bytes bytes", util.human_bytes(512) == "512B")
|
||||
check("human_bytes negative", util.human_bytes(-5) == "0B")
|
||||
|
||||
-- ---------- human_duration ----------
|
||||
check("human_duration 10天", util.human_duration(10 * 86400) == "10天")
|
||||
check("human_duration 8小时", util.human_duration(8 * 3600) == "8小时")
|
||||
check("human_duration 30分钟", util.human_duration(30 * 60) == "30分钟")
|
||||
check("human_duration expired", util.human_duration(-1) == "已过期")
|
||||
check("human_duration under minute", util.human_duration(30) == "不足1分钟")
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,35 @@
|
||||
-- http_proxy_test.lua — 代理地址校验单元测试
|
||||
-- 用法:lua5.1 tests/http_proxy_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local http = require("substore.http")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
local function check(name, cond)
|
||||
if cond then passed = passed + 1; print("PASS " .. name)
|
||||
else failed = failed + 1; print("FAIL " .. name) end
|
||||
end
|
||||
|
||||
-- ---------- parse_proxy 合法输入 ----------
|
||||
check("empty proxy", http.parse_proxy("") == "")
|
||||
check("http proxy", http.parse_proxy("http://192.168.5.1:8080") == "http://192.168.5.1:8080")
|
||||
check("socks5 proxy", http.parse_proxy("socks5://192.168.5.1:1080") == "socks5://192.168.5.1:1080")
|
||||
check("socks5h proxy", http.parse_proxy("socks5h://10.0.0.1:1080") == "socks5h://10.0.0.1:1080")
|
||||
check("https proxy", http.parse_proxy("https://proxy.example.com:8443") == "https://proxy.example.com:8443")
|
||||
check("auth proxy", http.parse_proxy("http://user:pass@192.168.5.1:8080") == "http://user:pass@192.168.5.1:8080")
|
||||
check("trim spaces", http.parse_proxy(" socks5://192.168.5.1:1080 ") == "socks5://192.168.5.1:1080")
|
||||
check("strip path", http.parse_proxy("http://192.168.5.1:8080/") == "http://192.168.5.1:8080")
|
||||
|
||||
-- ---------- parse_proxy 非法输入 ----------
|
||||
check("bad scheme", http.parse_proxy("ftp://x:80") == nil)
|
||||
check("no scheme", http.parse_proxy("192.168.5.1:1080") == nil)
|
||||
check("bad port", http.parse_proxy("http://x:99999") == nil)
|
||||
check("port zero", http.parse_proxy("http://x:0") == nil)
|
||||
check("injection host", http.parse_proxy("http://192.168.5.1:1080; rm -rf /") == nil)
|
||||
check("injection userinfo", http.parse_proxy("http://user;ls@192.168.5.1:1080") == nil)
|
||||
local _, e = http.parse_proxy("ftp://x:80")
|
||||
check("err is string", type(e) == "string")
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,177 @@
|
||||
-- node_converter_test.lua — 协议转换单元测试
|
||||
-- 用法:lua5.1 tests/node_converter_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local converter = require("substore.node_converter")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
local function eq(a, b)
|
||||
if type(a) ~= type(b) then return false end
|
||||
if type(a) == "table" then
|
||||
for k, v in pairs(a) do if not eq(v, b[k]) then return false end end
|
||||
for k in pairs(b) do if a[k] == nil then return false end end
|
||||
return true
|
||||
end
|
||||
return a == b
|
||||
end
|
||||
|
||||
-- ---------- 基本功能测试 ----------
|
||||
check("module exists", converter ~= nil)
|
||||
check("convert method exists", type(converter.convert) == "function")
|
||||
check("get_field_mapping method exists", type(converter.get_field_mapping) == "function")
|
||||
|
||||
-- ---------- vmess 转换测试 ----------
|
||||
local vmess_node = {
|
||||
proto = "vmess",
|
||||
server = "1.1.1.1",
|
||||
port = 443,
|
||||
uuid = "test-uuid-vmess",
|
||||
alterId = 0,
|
||||
cipher = "auto",
|
||||
network = "ws",
|
||||
}
|
||||
|
||||
local vless_converted = converter.convert(vmess_node, "vless")
|
||||
check("vmess to vless proto", vless_converted and vless_converted.proto == "vless")
|
||||
check("vmess to vless uuid", vless_converted and vless_converted.uuid == "test-uuid-vmess")
|
||||
check("vmess to vless server", vless_converted and vless_converted.server == "1.1.1.1")
|
||||
check("vmess to vless port", vless_converted and vless_converted.port == 443)
|
||||
|
||||
local trojan_converted = converter.convert(vmess_node, "trojan")
|
||||
check("vmess to trojan proto", trojan_converted and trojan_converted.proto == "trojan")
|
||||
check("vmess to trojan password", trojan_converted and trojan_converted.password == "test-uuid-vmess")
|
||||
check("vmess to trojan server", trojan_converted and trojan_converted.server == "1.1.1.1")
|
||||
|
||||
local ss_converted = converter.convert(vmess_node, "shadowsocks")
|
||||
check("vmess to ss proto", ss_converted and ss_converted.proto == "shadowsocks")
|
||||
check("vmess to ss password", ss_converted and ss_converted.password == "test-uuid-vmess")
|
||||
|
||||
-- ---------- vless 转换测试 ----------
|
||||
local vless_node = {
|
||||
proto = "vless",
|
||||
server = "2.2.2.2",
|
||||
port = 8443,
|
||||
uuid = "test-uuid-vless",
|
||||
security = "tls",
|
||||
}
|
||||
|
||||
local vmess_from_vless = converter.convert(vless_node, "vmess")
|
||||
check("vless to vmess proto", vmess_from_vless and vmess_from_vless.proto == "vmess")
|
||||
check("vless to vmess uuid", vmess_from_vless and vmess_from_vless.uuid == "test-uuid-vless")
|
||||
|
||||
local trojan_from_vless = converter.convert(vless_node, "trojan")
|
||||
check("vless to trojan proto", trojan_from_vless and trojan_from_vless.proto == "trojan")
|
||||
check("vless to trojan password", trojan_from_vless and trojan_from_vless.password == "test-uuid-vless")
|
||||
|
||||
-- ---------- trojan 转换测试 ----------
|
||||
local trojan_node = {
|
||||
proto = "trojan",
|
||||
server = "3.3.3.3",
|
||||
port = 443,
|
||||
password = "trojan-pass",
|
||||
sni = "example.com",
|
||||
}
|
||||
|
||||
local vmess_from_trojan = converter.convert(trojan_node, "vmess")
|
||||
check("trojan to vmess proto", vmess_from_trojan and vmess_from_trojan.proto == "vmess")
|
||||
check("trojan to vmess uuid", vmess_from_trojan and vmess_from_trojan.uuid == "trojan-pass")
|
||||
|
||||
local vless_from_trojan = converter.convert(trojan_node, "vless")
|
||||
check("trojan to vless proto", vless_from_trojan and vless_from_trojan.proto == "vless")
|
||||
check("trojan to vless uuid", vless_from_trojan and vless_from_trojan.uuid == "trojan-pass")
|
||||
|
||||
-- ---------- shadowsocks 转换测试 ----------
|
||||
local ss_node = {
|
||||
proto = "shadowsocks",
|
||||
server = "4.4.4.4",
|
||||
port = 8388,
|
||||
method = "aes-256-gcm",
|
||||
password = "ss-pass",
|
||||
}
|
||||
|
||||
local vmess_from_ss = converter.convert(ss_node, "vmess")
|
||||
check("ss to vmess proto", vmess_from_ss and vmess_from_ss.proto == "vmess")
|
||||
check("ss to vmess uuid", vmess_from_ss and vmess_from_ss.uuid == "ss-pass")
|
||||
|
||||
local vless_from_ss = converter.convert(ss_node, "vless")
|
||||
check("ss to vless proto", vless_from_ss and vless_from_ss.proto == "vless")
|
||||
check("ss to vless uuid", vless_from_ss and vless_from_ss.uuid == "ss-pass")
|
||||
|
||||
local trojan_from_ss = converter.convert(ss_node, "trojan")
|
||||
check("ss to trojan proto", trojan_from_ss and trojan_from_ss.proto == "trojan")
|
||||
check("ss to trojan password", trojan_from_ss and trojan_from_ss.password == "ss-pass")
|
||||
|
||||
-- ---------- 字段映射测试 ----------
|
||||
local mapping = converter.get_field_mapping("vmess", "vless")
|
||||
check("vmess to vless mapping exists", mapping ~= nil)
|
||||
check("vmess to vless has uuid", mapping and mapping.uuid == "uuid")
|
||||
check("vmess to vless has server", mapping and mapping.server == "server")
|
||||
|
||||
local mapping2 = converter.get_field_mapping("vless", "trojan")
|
||||
check("vless to trojan mapping exists", mapping2 ~= nil)
|
||||
check("vless to trojan uuid to password", mapping2 and mapping2.uuid == "password")
|
||||
|
||||
-- ---------- 不支持的转换 ----------
|
||||
local result = converter.convert(vmess_node, "unsupported_proto")
|
||||
check("unsupported target returns nil", result == nil)
|
||||
|
||||
-- ---------- 字段兼容性测试 ----------
|
||||
local vmess_with_net = {
|
||||
proto = "vmess",
|
||||
server = "5.5.5.5",
|
||||
port = 443,
|
||||
uuid = "uuid-1",
|
||||
network = "tcp",
|
||||
security = "tls",
|
||||
}
|
||||
local vless_with_net = converter.convert(vmess_with_net, "vless")
|
||||
check("network preserved", vless_with_net and vless_with_net.network == "tcp")
|
||||
check("security preserved", vless_with_net and vless_with_net.security == "tls")
|
||||
|
||||
-- ---------- hysteria2 测试 ----------
|
||||
local hysteria_node = {
|
||||
proto = "hysteria2",
|
||||
server = "6.6.6.6",
|
||||
port = 443,
|
||||
password = "hysteria-pass",
|
||||
}
|
||||
local singbox_from_hysteria = converter.convert(hysteria_node, "sing-box")
|
||||
check("hysteria2 to sing-box proto", singbox_from_hysteria and singbox_from_hysteria.proto == "sing-box")
|
||||
|
||||
-- ---------- tuic 测试 ----------
|
||||
local tuic_node = {
|
||||
proto = "tuic",
|
||||
server = "7.7.7.7",
|
||||
port = 443,
|
||||
uuid = "tuic-uuid",
|
||||
password = "tuic-pass",
|
||||
}
|
||||
local singbox_from_tuic = converter.convert(tuic_node, "sing-box")
|
||||
check("tuic to sing-box proto", singbox_from_tuic and singbox_from_tuic.proto == "sing-box")
|
||||
|
||||
-- ---------- wireguard 测试 ----------
|
||||
local wg_node = {
|
||||
proto = "wireguard",
|
||||
server = "8.8.8.8",
|
||||
port = 51820,
|
||||
private_key = "wg-private",
|
||||
public_key = "wg-public",
|
||||
}
|
||||
local singbox_from_wg = converter.convert(wg_node, "sing-box")
|
||||
check("wireguard to sing-box proto", singbox_from_wg and singbox_from_wg.proto == "sing-box")
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,88 @@
|
||||
-- node_extended_test.lua — 扩展字段单元测试
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local node = require("substore.node")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- normalize ----------
|
||||
local n1 = { proto = "vmess", server = "1.1.1.1", port = 443, group = "HK", tags = {"fast","vip"}, remarks = "test", template = "vmess://{uuid}@{server}:{port}", url = "https://example.com" }
|
||||
node.normalize(n1)
|
||||
check("normalize preserves group", n1.group == "HK")
|
||||
check("normalize preserves tags", type(n1.tags) == "table" and n1.tags[1] == "fast" and n1.tags[2] == "vip")
|
||||
check("normalize preserves remarks", n1.remarks == "test")
|
||||
check("normalize preserves template", n1.template == "vmess://{uuid}@{server}:{port}")
|
||||
check("normalize preserves url", n1.url == "https://example.com")
|
||||
|
||||
-- tags as string should be converted to table? Optional
|
||||
local n2 = { proto = "vless", server = "2.2.2.2", port = 443, tags = "fast,vip" }
|
||||
node.normalize(n2)
|
||||
-- If we choose to normalize tags string to table
|
||||
-- For now just check preservation
|
||||
check("normalize keeps tags string", n2.tags == "fast,vip" or (type(n2.tags)=="table" and n2.tags[1]=="fast"))
|
||||
|
||||
-- ---------- filter ----------
|
||||
local nodes = {
|
||||
{ proto = "vmess", server = "1.1.1.1", port = 443, group = "HK", tags = {"fast"} },
|
||||
{ proto = "vless", server = "2.2.2.2", port = 443, group = "JP", tags = {"slow"} },
|
||||
{ proto = "vmess", server = "3.3.3.3", port = 443, group = "HK", tags = {"fast","vip"} },
|
||||
}
|
||||
local f1 = node.filter(nodes, { group = "HK" })
|
||||
check("filter by group count", #f1 == 2)
|
||||
check("filter by group values", f1[1].group == "HK" and f1[2].group == "HK")
|
||||
|
||||
local f2 = node.filter(nodes, { tags = "vip" })
|
||||
check("filter by tags count", #f2 == 1)
|
||||
check("filter by tags value", f2[1].server == "3.3.3.3")
|
||||
|
||||
-- ---------- apply_rules ----------
|
||||
local nodes2 = {
|
||||
{ proto = "vmess", name = "A", group = "HK" },
|
||||
{ proto = "vless", name = "B", group = "JP" },
|
||||
{ proto = "vmess", name = "C", group = "HK" },
|
||||
}
|
||||
local rules = { group_filter = "HK" }
|
||||
local r1 = node.apply_rules(nodes2, rules)
|
||||
check("apply_rules group_filter count", #r1 == 2)
|
||||
check("apply_rules group_filter values", r1[1].group == "HK" and r1[2].group == "HK")
|
||||
|
||||
local nodes3 = {
|
||||
{ proto = "vmess", name = "A", tags = {"fast"} },
|
||||
{ proto = "vmess", name = "B", tags = {"slow"} },
|
||||
}
|
||||
local rules2 = { tags_include = "fast" }
|
||||
local r2 = node.apply_rules(nodes3, rules2)
|
||||
check("apply_rules tags_include count", #r2 == 1)
|
||||
check("apply_rules tags_include value", r2[1].name == "A")
|
||||
|
||||
-- ---------- group_nodes ----------
|
||||
local nodes4 = {
|
||||
{ name = "A", group = "HK" },
|
||||
{ name = "B", group = "JP" },
|
||||
{ name = "C", group = "HK" },
|
||||
}
|
||||
local groups = node.group_nodes(nodes4)
|
||||
check("group_nodes HK count", groups["HK"] and #groups["HK"] == 2)
|
||||
check("group_nodes JP count", groups["JP"] and #groups["JP"] == 1)
|
||||
|
||||
-- ---------- add_tags ----------
|
||||
local nodes5 = {
|
||||
{ name = "A", tags = {"fast"} },
|
||||
{ name = "B" },
|
||||
}
|
||||
node.add_tags(nodes5, {"vip"})
|
||||
check("add_tags existing", nodes5[1].tags and #nodes5[1].tags == 2 and nodes5[1].tags[2] == "vip")
|
||||
check("add_tags new", nodes5[2].tags and #nodes5[2].tags == 1 and nodes5[2].tags[1] == "vip")
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,132 @@
|
||||
-- node_group_test.lua — 节点分组与标签测试
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local node = require("substore.node")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
local function eq(a, b)
|
||||
if type(a) ~= type(b) then return false end
|
||||
if type(a) == "table" then
|
||||
for k, v in pairs(a) do if not eq(v, b[k]) then return false end end
|
||||
for k in pairs(b) do if a[k] == nil then return false end end
|
||||
return true
|
||||
end
|
||||
return a == b
|
||||
end
|
||||
|
||||
-- 测试数据
|
||||
local nodes = {
|
||||
{ name = "HK VMESS 1", proto = "vmess", server = "1.1.1.1", port = 443 },
|
||||
{ name = "HK VMESS 2", proto = "vmess", server = "1.1.1.2", port = 443 },
|
||||
{ name = "US TROJAN 1", proto = "trojan", server = "2.2.2.2", port = 443 },
|
||||
{ name = "JP SS 1", proto = "shadowsocks", server = "3.3.3.3", port = 8388 },
|
||||
{ name = "HK TROJAN 2", proto = "trojan", server = "1.1.1.1", port = 443 },
|
||||
}
|
||||
|
||||
-- ---------- group_by ----------
|
||||
check("group_by proto exists", type(node.group_by) == "function")
|
||||
if type(node.group_by) == "function" then
|
||||
local groups = node.group_by(nodes, "proto")
|
||||
check("group_by proto count", groups ~= nil and groups.vmess ~= nil and #groups.vmess == 2)
|
||||
check("group_by proto trojan", groups ~= nil and groups.trojan ~= nil and #groups.trojan == 2)
|
||||
check("group_by proto shadowsocks", groups ~= nil and groups.shadowsocks ~= nil and #groups.shadowsocks == 1)
|
||||
|
||||
local groups_server = node.group_by(nodes, "server")
|
||||
check("group_by server 1.1.1.1", groups_server ~= nil and groups_server["1.1.1.1"] ~= nil and #groups_server["1.1.1.1"] == 2)
|
||||
check("group_by server 2.2.2.2", groups_server ~= nil and groups_server["2.2.2.2"] ~= nil and #groups_server["2.2.2.2"] == 1)
|
||||
end
|
||||
|
||||
-- ---------- add_group ----------
|
||||
check("add_group exists", type(node.add_group) == "function")
|
||||
if type(node.add_group) == "function" then
|
||||
local nodes_copy = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
nodes_copy[#nodes_copy + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port }
|
||||
end
|
||||
|
||||
-- 按协议分组
|
||||
local rules_proto = {
|
||||
{ field = "proto", prefix = "proto_" }
|
||||
}
|
||||
local result = node.add_group(nodes_copy, rules_proto)
|
||||
check("add_group proto field exists", result[1].group ~= nil)
|
||||
check("add_group proto value", result[1].group == "proto_vmess")
|
||||
|
||||
-- 按服务器分组
|
||||
local nodes_copy2 = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
nodes_copy2[#nodes_copy2 + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port }
|
||||
end
|
||||
local rules_server = {
|
||||
{ field = "server", prefix = "server_" }
|
||||
}
|
||||
local result2 = node.add_group(nodes_copy2, rules_server)
|
||||
check("add_group server field exists", result2[1].group ~= nil)
|
||||
check("add_group server value", result2[1].group == "server_1.1.1.1")
|
||||
end
|
||||
|
||||
-- ---------- add_tags ----------
|
||||
check("add_tags exists", type(node.add_tags) == "function")
|
||||
if type(node.add_tags) == "function" then
|
||||
local nodes_copy = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
nodes_copy[#nodes_copy + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port }
|
||||
end
|
||||
|
||||
-- 按关键词添加标签
|
||||
local rules_keyword = {
|
||||
{ type = "keyword", value = "HK", tag = "hongkong" }
|
||||
}
|
||||
local result = node.add_tags(nodes_copy, rules_keyword)
|
||||
check("add_tags keyword exists", result[1].tags ~= nil and type(result[1].tags) == "table")
|
||||
check("add_tags keyword value", result[1].tags ~= nil and result[1].tags.hongkong == true)
|
||||
check("add_tags keyword not match", result[3].tags ~= nil and result[3].tags.hongkong == nil)
|
||||
end
|
||||
|
||||
-- ---------- filter_by_group ----------
|
||||
check("filter_by_group exists", type(node.filter_by_group) == "function")
|
||||
if type(node.filter_by_group) == "function" then
|
||||
local nodes_copy = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
nodes_copy[#nodes_copy + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port, group = (n.proto == "vmess") and "proto_vmess" or "other" }
|
||||
end
|
||||
|
||||
local filtered = node.filter_by_group(nodes_copy, "proto_vmess")
|
||||
check("filter_by_group count", filtered ~= nil and #filtered == 2)
|
||||
|
||||
local filtered_none = node.filter_by_group(nodes_copy, "proto_trojan")
|
||||
check("filter_by_group none", filtered_none ~= nil and #filtered_none == 0)
|
||||
end
|
||||
|
||||
-- ---------- filter_by_tags ----------
|
||||
check("filter_by_tags exists", type(node.filter_by_tags) == "function")
|
||||
if type(node.filter_by_tags) == "function" then
|
||||
local nodes_copy = {}
|
||||
for _, n in ipairs(nodes) do
|
||||
nodes_copy[#nodes_copy + 1] = { name = n.name, proto = n.proto, server = n.server, port = n.port, tags = {} }
|
||||
end
|
||||
nodes_copy[1].tags.hongkong = true
|
||||
nodes_copy[2].tags.hongkong = true
|
||||
nodes_copy[3].tags.usa = true
|
||||
|
||||
local filtered = node.filter_by_tags(nodes_copy, { "hongkong" })
|
||||
check("filter_by_tags count", filtered ~= nil and #filtered == 2)
|
||||
|
||||
local filtered_none = node.filter_by_tags(nodes_copy, { "japan" })
|
||||
check("filter_by_tags none", filtered_none ~= nil and #filtered_none == 0)
|
||||
end
|
||||
|
||||
-- 结果
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,123 @@
|
||||
-- node_rename_test.lua — 节点重命名规则引擎单元测试
|
||||
-- 用法:lua5.1 tests/node_rename_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local node = require("substore.node")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- 辅助:创建节点
|
||||
local function mk(proto, server, port, name)
|
||||
return { proto = proto, server = server, port = port, name = name or (server .. ":" .. port) }
|
||||
end
|
||||
|
||||
-- ---------- parse_rename_rules ----------
|
||||
check("parse empty rules", #node.parse_rename_rules("") == 0)
|
||||
|
||||
local rules1 = node.parse_rename_rules("^(.*):(\\d+)$ -> $1")
|
||||
check("parse regex rule count", #rules1 == 1)
|
||||
check("parse regex rule type", rules1[1] and rules1[1].type == "regex")
|
||||
check("parse regex pattern", rules1[1] and rules1[1].pattern == "^(.*):(\\d+)$")
|
||||
check("parse regex replacement", rules1[1] and rules1[1].replacement == "$1")
|
||||
|
||||
local rules2 = node.parse_rename_rules("vmess -> VMESS")
|
||||
check("parse simple replace", #rules2 == 1 and rules2[1].type == "regex")
|
||||
check("parse simple pattern", rules2[1].pattern == "vmess")
|
||||
check("parse simple replacement", rules2[1].replacement == "VMESS")
|
||||
|
||||
local rules3 = node.parse_rename_rules("{server}_{port}_{proto}")
|
||||
check("parse template rule count", #rules3 == 1)
|
||||
check("parse template rule type", rules3[1] and rules3[1].type == "template")
|
||||
check("parse template content", rules3[1] and rules3[1].template == "{server}_{port}_{proto}")
|
||||
|
||||
local rules4 = node.parse_rename_rules([[
|
||||
^(.*):(\d+)$ -> $1
|
||||
vmess -> VMESS
|
||||
{server}_{port}_{proto}
|
||||
]])
|
||||
check("parse multiple rules", #rules4 == 3)
|
||||
|
||||
-- 精确匹配:旧名称=新名称
|
||||
local rules5 = node.parse_rename_rules("香港=HK")
|
||||
check("parse exact rule count", #rules5 == 1)
|
||||
check("parse exact rule type", rules5[1] and rules5[1].type == "exact")
|
||||
check("parse exact old", rules5[1] and rules5[1].old == "香港")
|
||||
check("parse exact new", rules5[1] and rules5[1].new == "HK")
|
||||
|
||||
local rules6 = node.parse_rename_rules(" 香港 = HK ")
|
||||
check("parse exact trim", rules6[1] and rules6[1].old == "香港" and rules6[1].new == "HK")
|
||||
|
||||
-- 注释行应被忽略
|
||||
local rules7 = node.parse_rename_rules("# 注释\n香港=HK")
|
||||
check("parse exact skips comment", #rules7 == 1 and rules7[1].type == "exact")
|
||||
|
||||
-- ---------- rename_with_rules ----------
|
||||
-- 测试正则替换
|
||||
local nodes1 = { mk("vmess", "1.2.3.4", 443, "1.2.3.4:443") }
|
||||
node.rename_with_rules(nodes1, node.parse_rename_rules("^(.*):(\\d+)$ -> $1"))
|
||||
check("regex rename strip port", nodes1[1].name == "1.2.3.4")
|
||||
|
||||
-- 测试简单替换
|
||||
local nodes2 = { mk("vmess", "1.2.3.4", 443, "vmess node") }
|
||||
node.rename_with_rules(nodes2, node.parse_rename_rules("vmess -> VMESS"))
|
||||
check("simple replace vmess", nodes2[1].name == "VMESS node")
|
||||
|
||||
-- 测试模板变量
|
||||
local nodes3 = { mk("vmess", "1.2.3.4", 443, "old") }
|
||||
node.rename_with_rules(nodes3, node.parse_rename_rules("{server}_{port}_{proto}"))
|
||||
check("template rename", nodes3[1].name == "1.2.3.4_443_vmess")
|
||||
|
||||
-- 测试链式规则
|
||||
local nodes4 = { mk("vmess", "1.2.3.4", 443, "1.2.3.4:443") }
|
||||
node.rename_with_rules(nodes4, node.parse_rename_rules([[
|
||||
^(.*):(\d+)$ -> $1
|
||||
vmess -> VMESS
|
||||
]]))
|
||||
-- 第一步去掉端口 -> 1.2.3.4, 第二步 vmess->VMESS 不匹配
|
||||
check("chain rule 1", nodes4[1].name == "1.2.3.4")
|
||||
|
||||
-- 测试模板后接替换
|
||||
local nodes5 = { mk("vmess", "1.2.3.4", 443, "old") }
|
||||
node.rename_with_rules(nodes5, node.parse_rename_rules([[
|
||||
{server}_{port}_{proto}
|
||||
vmess -> VMESS
|
||||
]]))
|
||||
check("template then replace", nodes5[1].name == "1.2.3.4_443_VMESS")
|
||||
|
||||
-- 测试精确匹配重命名
|
||||
local nodes6 = { mk("vmess", "1.2.3.4", 443, "香港") }
|
||||
node.rename_with_rules(nodes6, node.parse_rename_rules("香港=HK"))
|
||||
check("exact rename match", nodes6[1].name == "HK")
|
||||
|
||||
-- 精确匹配不命中时不改动
|
||||
local nodes7 = { mk("vmess", "1.2.3.4", 443, "东京") }
|
||||
node.rename_with_rules(nodes7, node.parse_rename_rules("香港=HK"))
|
||||
check("exact rename no match", nodes7[1].name == "东京")
|
||||
|
||||
-- 精确匹配 + 模板链式
|
||||
local nodes8 = { mk("vmess", "1.2.3.4", 443, "香港") }
|
||||
node.rename_with_rules(nodes8, node.parse_rename_rules([[
|
||||
香港=HK
|
||||
{name}_{port}
|
||||
]]))
|
||||
check("exact then template", nodes8[1].name == "HK_443")
|
||||
|
||||
-- 保持向后兼容:M.rename
|
||||
local n = mk("vmess", "1.1.1.1", 443, "old")
|
||||
node.rename(n, "new")
|
||||
check("backward compat rename", n.name == "new")
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,152 @@
|
||||
-- output_clash_meta_test.lua — Clash.Meta / Mihomo 输出单元测试
|
||||
-- 用法:lua5.1 tests/output_clash_meta_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local output_clash_meta = require("substore.output_clash_meta")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- 基础功能测试 ----------
|
||||
check("module exists", output_clash_meta ~= nil)
|
||||
check("generate method exists", type(output_clash_meta.generate) == "function")
|
||||
|
||||
-- ---------- 测试节点 ----------
|
||||
local test_nodes = {
|
||||
{
|
||||
proto = "vmess",
|
||||
name = "TestVMess",
|
||||
server = "1.1.1.1",
|
||||
port = 443,
|
||||
uuid = "abc-123",
|
||||
net = "ws",
|
||||
security = "tls",
|
||||
sni = "example.com",
|
||||
path = "/ws",
|
||||
host = "example.com",
|
||||
},
|
||||
{
|
||||
proto = "vless",
|
||||
name = "TestVLESS",
|
||||
server = "2.2.2.2",
|
||||
port = 443,
|
||||
uuid = "vless-uuid-123",
|
||||
net = "tcp",
|
||||
security = "tls",
|
||||
sni = "example.org",
|
||||
alpn = "h2,http/1.1",
|
||||
fp = "chrome",
|
||||
},
|
||||
{
|
||||
proto = "trojan",
|
||||
name = "TestTrojan",
|
||||
server = "3.3.3.3",
|
||||
port = 443,
|
||||
password = "trojan-pass",
|
||||
sni = "example.com",
|
||||
["skip-cert-verify"] = true,
|
||||
udp = true,
|
||||
},
|
||||
{
|
||||
proto = "shadowsocks",
|
||||
name = "TestSS",
|
||||
server = "4.4.4.4",
|
||||
port = 8388,
|
||||
method = "aes-256-gcm",
|
||||
password = "ss-pass",
|
||||
},
|
||||
{
|
||||
proto = "hysteria2",
|
||||
name = "TestHysteria2",
|
||||
server = "5.5.5.5",
|
||||
port = 443,
|
||||
password = "hy2-pass",
|
||||
sni = "hy2.example.com",
|
||||
alpn = "h3",
|
||||
},
|
||||
{
|
||||
proto = "tuic",
|
||||
name = "TestTUIC",
|
||||
server = "6.6.6.6",
|
||||
port = 443,
|
||||
uuid = "tuic-uuid",
|
||||
password = "tuic-pass",
|
||||
["udp-relay-mode"] = "native",
|
||||
},
|
||||
{
|
||||
proto = "wireguard",
|
||||
name = "TestWireGuard",
|
||||
server = "7.7.7.7",
|
||||
port = 51820,
|
||||
["private-key"] = "wg-private",
|
||||
["peer-public-key"] = "wg-peer",
|
||||
["preshared-key"] = "wg-psk",
|
||||
},
|
||||
{
|
||||
proto = "socks",
|
||||
name = "TestSocks",
|
||||
server = "8.8.8.8",
|
||||
port = 1080,
|
||||
username = "user",
|
||||
password = "pass",
|
||||
},
|
||||
}
|
||||
|
||||
-- ---------- 生成测试 ----------
|
||||
local options = {
|
||||
name = "TestGroup",
|
||||
latency_test = true,
|
||||
health_check = true,
|
||||
}
|
||||
|
||||
local yaml_output = output_clash_meta.generate(test_nodes, options)
|
||||
|
||||
check("generate returns string", type(yaml_output) == "string")
|
||||
check("output contains proxies", yaml_output and yaml_output:find("proxies:") ~= nil)
|
||||
check("output contains proxy-groups", yaml_output and yaml_output:find("proxy%-groups:") ~= nil)
|
||||
check("output contains vmess node", yaml_output and yaml_output:find("TestVMess") ~= nil)
|
||||
check("output contains vless node", yaml_output and yaml_output:find("TestVLESS") ~= nil)
|
||||
check("output contains trojan node", yaml_output and yaml_output:find("TestTrojan") ~= nil)
|
||||
check("output contains shadowsocks node", yaml_output and yaml_output:find("TestSS") ~= nil)
|
||||
check("output contains hysteria2 node", yaml_output and yaml_output:find("TestHysteria2") ~= nil)
|
||||
check("output contains tuic node", yaml_output and yaml_output:find("TestTUIC") ~= nil)
|
||||
check("output contains wireguard node", yaml_output and yaml_output:find("TestWireGuard") ~= nil)
|
||||
check("output contains socks node", yaml_output and yaml_output:find("TestSocks") ~= nil)
|
||||
|
||||
-- 检查协议类型映射
|
||||
check("vmess type present", yaml_output and yaml_output:find("type: vmess") ~= nil)
|
||||
check("vless type present", yaml_output and yaml_output:find("type: vless") ~= nil)
|
||||
check("trojan type present", yaml_output and yaml_output:find("type: trojan") ~= nil)
|
||||
check("ss type present", yaml_output and yaml_output:find("type: ss") ~= nil)
|
||||
|
||||
-- 检查特殊字段
|
||||
check("skip-cert-verify present", yaml_output and yaml_output:find("skip%-cert%-verify") ~= nil)
|
||||
check("udp present", yaml_output and yaml_output:find("udp:") ~= nil)
|
||||
check("alpn present", yaml_output and yaml_output:find("alpn:") ~= nil)
|
||||
check("fp present", yaml_output and yaml_output:find("fp:") ~= nil)
|
||||
|
||||
-- 检查 proxy-groups
|
||||
check("SELECT group present", yaml_output and yaml_output:find("type: select") ~= nil)
|
||||
check("URL-TEST group present", yaml_output and yaml_output:find("type: url%-test") ~= nil)
|
||||
check("LOAD-BALANCE group present", yaml_output and yaml_output:find("type: load%-balance") ~= nil)
|
||||
|
||||
-- 检查选项
|
||||
check("name option used", yaml_output and yaml_output:find("TestGroup") ~= nil)
|
||||
|
||||
-- ---------- 空节点测试 ----------
|
||||
local empty_output = output_clash_meta.generate({}, {})
|
||||
check("empty nodes handled", type(empty_output) == "string")
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,98 @@
|
||||
-- output_formats_test.lua — 13 种目标格式统一分发单元测试
|
||||
-- 用法:lua5.1 tests/output_formats_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local output = require("substore.output")
|
||||
local util = require("substore.util")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
local nodes = {
|
||||
{ proto = "vmess", name = "VMess Node", server = "1.1.1.1", port = 443,
|
||||
uuid = "abc-123", net = "ws", security = "tls", sni = "example.com",
|
||||
path = "/ws", host = "example.com" },
|
||||
{ proto = "vless", name = "VLESS Node", server = "2.2.2.2", port = 443,
|
||||
uuid = "vless-uuid", security = "tls", sni = "example.org" },
|
||||
{ proto = "trojan", name = "Trojan Node", server = "3.3.3.3", port = 443,
|
||||
password = "trojan-pass", sni = "example.com" },
|
||||
{ proto = "shadowsocks", name = "SS Node", server = "4.4.4.4", port = 8388,
|
||||
method = "aes-256-gcm", password = "ss-pass" },
|
||||
}
|
||||
|
||||
check("module exists", output ~= nil)
|
||||
check("generate exists", type(output.generate) == "function")
|
||||
|
||||
-- 13 种目标格式均返回字符串
|
||||
local formats = {
|
||||
"clash", "clashmeta", "mihomo", "stash",
|
||||
"surge", "surfboard", "surgemac", "loon", "egern",
|
||||
"qx", "singbox", "v2ray", "v2rayuri", "shadowrocket", "plain",
|
||||
}
|
||||
|
||||
for _, f in ipairs(formats) do
|
||||
local s, err = output.generate(nodes, f)
|
||||
check("generate " .. f .. " returns string", type(s) == "string" and (not err))
|
||||
print(" ", err or "")
|
||||
end
|
||||
|
||||
-- 格式头检查
|
||||
local surge = output.generate(nodes, "surge")
|
||||
check("surge has [Proxy]", surge:find("%[Proxy%]") ~= nil)
|
||||
check("surge has vmess line", surge:find("VMess Node = vmess") ~= nil)
|
||||
check("surge has ss line", surge:find("SS Node = shadowsocks") ~= nil)
|
||||
|
||||
local qx = output.generate(nodes, "qx")
|
||||
check("qx has [server_local]", qx:find("%[server_local%]") ~= nil)
|
||||
check("qx has vmess line", qx:find("vmess=1.1.1.1:443") ~= nil)
|
||||
check("qx has policy", qx:find("%[policy%]") ~= nil)
|
||||
|
||||
local singbox = output.generate(nodes, "singbox")
|
||||
check("singbox is json", singbox:find("{") == 1)
|
||||
local sb = util.json_decode(singbox)
|
||||
check("singbox has outbounds", sb and sb.outbounds ~= nil and #sb.outbounds == 4)
|
||||
check("singbox type vmess", sb and sb.outbounds[1] and sb.outbounds[1].type == "vmess")
|
||||
|
||||
local v2ray = output.generate(nodes, "v2ray")
|
||||
local vr = util.json_decode(v2ray)
|
||||
check("v2ray has outbounds", vr and vr.outbounds ~= nil and #vr.outbounds == 4)
|
||||
check("v2ray protocol vmess", vr and vr.outbounds[1] and vr.outbounds[1].protocol == "vmess")
|
||||
|
||||
local uri = output.generate(nodes, "v2rayuri")
|
||||
check("uri list has vmess", uri:find("vmess://") ~= nil)
|
||||
check("uri list has vless", uri:find("vless://") ~= nil)
|
||||
check("uri list has trojan", uri:find("trojan://") ~= nil)
|
||||
check("uri list has ss", uri:find("ss://") ~= nil)
|
||||
|
||||
local rocket = output.generate(nodes, "shadowrocket")
|
||||
local dec = util.base64_decode(rocket)
|
||||
check("shadowrocket is base64 of uri", dec:find("vmess://") ~= nil)
|
||||
|
||||
local plain = output.generate(nodes, "plain")
|
||||
local pj = util.json_decode(plain)
|
||||
check("plain is json array", type(pj) == "table" and pj[1] ~= nil and pj[1].proto == "vmess")
|
||||
|
||||
-- 别名映射
|
||||
check("alias clashmeta", output.generate(nodes, "clashmeta") == output.generate(nodes, "clash"))
|
||||
check("alias sing_box", output.generate(nodes, "sing_box") == output.generate(nodes, "singbox"))
|
||||
|
||||
-- 未知格式报错
|
||||
local bad, err = output.generate(nodes, "nonsense")
|
||||
check("unknown format returns nil", bad == nil)
|
||||
check("unknown format has err", err ~= nil)
|
||||
|
||||
-- 空节点
|
||||
check("empty nodes ok", type(output.generate({}, "surge")) == "string")
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,226 @@
|
||||
-- parser_clash_yaml_test.lua — Clash YAML 解析单元测试
|
||||
-- 用法:lua5.1 tests/parser_clash_yaml_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local parser = require("substore.parser_clash_yaml")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- 基本 Clash YAML ----------
|
||||
local clash_yaml_basic = [[
|
||||
proxies:
|
||||
- name: TestVMess
|
||||
type: vmess
|
||||
server: 1.1.1.1
|
||||
port: 443
|
||||
uuid: abc-123
|
||||
alterId: 0
|
||||
cipher: auto
|
||||
network: ws
|
||||
ws-opts:
|
||||
path: /ws
|
||||
headers:
|
||||
Host: example.com
|
||||
tls: true
|
||||
servername: example.com
|
||||
- name: TestVLESS
|
||||
type: vless
|
||||
server: 2.2.2.2
|
||||
port: 443
|
||||
uuid: vless-uuid-123
|
||||
network: tcp
|
||||
tls: true
|
||||
sni: example.org
|
||||
alpn:
|
||||
- h2
|
||||
- http/1.1
|
||||
fp: chrome
|
||||
- name: TestTrojan
|
||||
type: trojan
|
||||
server: 3.3.3.3
|
||||
port: 443
|
||||
password: trojan-pass
|
||||
sni: example.com
|
||||
skip-cert-verify: true
|
||||
udp: true
|
||||
- name: TestSS
|
||||
type: ss
|
||||
server: 4.4.4.4
|
||||
port: 8388
|
||||
cipher: aes-256-gcm
|
||||
password: ss-pass
|
||||
- name: TestHysteria2
|
||||
type: hysteria2
|
||||
server: 5.5.5.5
|
||||
port: 443
|
||||
password: hy2-pass
|
||||
sni: hy2.example.com
|
||||
alpn:
|
||||
- h3
|
||||
- name: TestTUIC
|
||||
type: tuic
|
||||
server: 6.6.6.6
|
||||
port: 443
|
||||
uuid: tuic-uuid
|
||||
password: tuic-pass
|
||||
udp-relay-mode: native
|
||||
- name: TestWireGuard
|
||||
type: wireguard
|
||||
server: 7.7.7.7
|
||||
port: 51820
|
||||
uuid: wg-uuid
|
||||
private-key: wg-private
|
||||
peer-public-key: wg-peer
|
||||
preshared-key: wg-psk
|
||||
- name: TestSocks
|
||||
type: socks5
|
||||
server: 8.8.8.8
|
||||
port: 1080
|
||||
username: user
|
||||
password: pass
|
||||
]]
|
||||
|
||||
local nodes = parser.parse(clash_yaml_basic)
|
||||
check("parse returns table", type(nodes) == "table")
|
||||
check("node count", nodes and #nodes == 8)
|
||||
|
||||
if nodes and #nodes >= 1 then
|
||||
local n1 = nodes[1]
|
||||
check("vmess name", n1.name == "TestVMess")
|
||||
check("vmess proto", n1.proto == "vmess")
|
||||
check("vmess server", n1.server == "1.1.1.1")
|
||||
check("vmess port", n1.port == 443)
|
||||
check("vmess uuid", n1.uuid == "abc-123")
|
||||
check("vmess net", n1.net == "ws")
|
||||
check("vmess sni", n1.sni == "example.com")
|
||||
end
|
||||
|
||||
if nodes and #nodes >= 2 then
|
||||
local n2 = nodes[2]
|
||||
check("vless name", n2.name == "TestVLESS")
|
||||
check("vless proto", n2.proto == "vless")
|
||||
check("vless server", n2.server == "2.2.2.2")
|
||||
check("vless port", n2.port == 443)
|
||||
check("vless uuid", n2.uuid == "vless-uuid-123")
|
||||
check("vless sni", n2.sni == "example.org")
|
||||
check("vless alpn", n2.alpn ~= nil)
|
||||
check("vless fp", n2.fp == "chrome")
|
||||
end
|
||||
|
||||
if nodes and #nodes >= 3 then
|
||||
local n3 = nodes[3]
|
||||
check("trojan name", n3.name == "TestTrojan")
|
||||
check("trojan proto", n3.proto == "trojan")
|
||||
check("trojan server", n3.server == "3.3.3.3")
|
||||
check("trojan port", n3.port == 443)
|
||||
check("trojan password", n3.password == "trojan-pass")
|
||||
check("trojan sni", n3.sni == "example.com")
|
||||
check("trojan skip-cert-verify", n3["skip-cert-verify"] == true or n3.skip_cert_verify == true)
|
||||
check("trojan udp", n3.udp == true)
|
||||
end
|
||||
|
||||
if nodes and #nodes >= 4 then
|
||||
local n4 = nodes[4]
|
||||
check("ss name", n4.name == "TestSS")
|
||||
check("ss proto", n4.proto == "shadowsocks")
|
||||
check("ss server", n4.server == "4.4.4.4")
|
||||
check("ss port", n4.port == 8388)
|
||||
check("ss method", n4.method == "aes-256-gcm")
|
||||
check("ss password", n4.password == "ss-pass")
|
||||
end
|
||||
|
||||
if nodes and #nodes >= 5 then
|
||||
local n5 = nodes[5]
|
||||
check("hysteria2 name", n5.name == "TestHysteria2")
|
||||
check("hysteria2 proto", n5.proto == "hysteria2")
|
||||
check("hysteria2 server", n5.server == "5.5.5.5")
|
||||
check("hysteria2 port", n5.port == 443)
|
||||
check("hysteria2 password", n5.password == "hy2-pass")
|
||||
end
|
||||
|
||||
if nodes and #nodes >= 6 then
|
||||
local n6 = nodes[6]
|
||||
check("tuic name", n6.name == "TestTUIC")
|
||||
check("tuic proto", n6.proto == "tuic")
|
||||
check("tuic server", n6.server == "6.6.6.6")
|
||||
end
|
||||
|
||||
if nodes and #nodes >= 7 then
|
||||
local n7 = nodes[7]
|
||||
check("wireguard name", n7.name == "TestWireGuard")
|
||||
check("wireguard proto", n7.proto == "wireguard")
|
||||
check("wireguard server", n7.server == "7.7.7.7")
|
||||
end
|
||||
|
||||
if nodes and #nodes >= 8 then
|
||||
local n8 = nodes[8]
|
||||
check("socks name", n8.name == "TestSocks")
|
||||
check("socks proto", n8.proto == "socks")
|
||||
check("socks server", n8.server == "8.8.8.8")
|
||||
end
|
||||
|
||||
-- ---------- proxy-groups ----------
|
||||
local clash_yaml_groups = [[
|
||||
proxies:
|
||||
- name: Node1
|
||||
type: vmess
|
||||
server: 1.1.1.1
|
||||
port: 443
|
||||
uuid: u1
|
||||
proxy-groups:
|
||||
- name: Group1
|
||||
type: select
|
||||
proxies:
|
||||
- Node1
|
||||
- REJECT
|
||||
]]
|
||||
|
||||
local nodes2 = parser.parse(clash_yaml_groups)
|
||||
check("groups parse nodes", nodes2 and #nodes2 == 1)
|
||||
check("groups parse name", nodes2 and nodes2[1].name == "Node1")
|
||||
|
||||
-- ---------- 流式 JSON 节点(机场 clash 配置常见写法)----------
|
||||
local clash_yaml_flow = [[
|
||||
proxies:
|
||||
- {"name":"V1-FlowVMess","type":"vmess","server":"a.cdn.node.example.com","port":30849,"uuid":"u-1","alterId":0,"cipher":"auto","udp":true,"tags":"hk","network":"ws","ws-opts":{"path":"/009c.x.m3u8","headers":{"Host":"edge.example.com"}},"skip-cert-verify":false}
|
||||
- {"name":"V1-FlowSSR","type":"ssr","server":"b.cdn.node.example.com","port":30800,"cipher":"chacha20-ietf","password":"pwd","protocol":"auth_aes128_sha1","protocol-param":"174188:MUQuUs","obfs":"tls1.2_ticket_auth","obfs-param":"cdn.com","udp":true,"tags":"jp"}
|
||||
]]
|
||||
|
||||
local fnodes = parser.parse(clash_yaml_flow)
|
||||
check("flow node count", fnodes and #fnodes == 2)
|
||||
|
||||
if fnodes and #fnodes >= 1 then
|
||||
local f1 = fnodes[1]
|
||||
check("flow vmess proto", f1.proto == "vmess")
|
||||
check("flow vmess name", f1.name == "V1-FlowVMess")
|
||||
check("flow vmess server", f1.server == "a.cdn.node.example.com")
|
||||
check("flow vmess port", f1.port == 30849)
|
||||
check("flow vmess uuid", f1.uuid == "u-1")
|
||||
check("flow vmess net", f1.net == "ws")
|
||||
check("flow vmess path", f1.path == "/009c.x.m3u8")
|
||||
check("flow vmess host", f1.host == "edge.example.com")
|
||||
end
|
||||
|
||||
if fnodes and #fnodes >= 2 then
|
||||
local f2 = fnodes[2]
|
||||
check("flow ssr proto", f2.proto == "ssr")
|
||||
check("flow ssr server", f2.server == "b.cdn.node.example.com")
|
||||
check("flow ssr cipher", f2.method == "chacha20-ietf")
|
||||
check("flow ssr protocol", f2.protocol == "auth_aes128_sha1")
|
||||
check("flow ssr obfs", f2.obfs == "tls1.2_ticket_auth")
|
||||
end
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,109 @@
|
||||
-- parser_input_test.lua — 多客户端配置导入(sing-box / V2Ray / Clash JSON / Surge / QX)单元测试
|
||||
-- 用法:lua5.1 tests/parser_input_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local parser = require("substore.parser")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
check("module exists", parser ~= nil)
|
||||
check("parse exists", type(parser.parse) == "function")
|
||||
|
||||
-- ---------- sing-box JSON ----------
|
||||
local singbox = [[
|
||||
{
|
||||
"outbounds": [
|
||||
{ "type": "vmess", "tag": "sb-vmess", "server": "1.1.1.1", "server_port": 443, "uuid": "u1",
|
||||
"tls": { "enabled": true, "server_name": "sni.example.com" } },
|
||||
{ "type": "trojan", "tag": "sb-trojan", "server": "2.2.2.2", "server_port": 443, "password": "p1" },
|
||||
{ "type": "shadowsocks", "tag": "sb-ss", "server": "3.3.3.3", "server_port": 8388, "method": "aes-256-gcm", "password": "p2" }
|
||||
]
|
||||
}
|
||||
]]
|
||||
|
||||
check("detect singbox = json", parser.detect(singbox) == "json")
|
||||
local r1 = parser.parse(singbox)
|
||||
check("singbox parses", r1 and r1.nodes ~= nil)
|
||||
check("singbox 3 nodes", r1 and #r1.nodes == 3)
|
||||
check("singbox vmess uuid", r1 and r1.nodes[1] and r1.nodes[1].uuid == "u1")
|
||||
check("singbox vmess sni", r1 and r1.nodes[1] and r1.nodes[1].sni == "sni.example.com")
|
||||
check("singbox trojan pass", r1 and r1.nodes[2] and r1.nodes[2].password == "p1")
|
||||
|
||||
-- ---------- V2Ray JSON ----------
|
||||
local v2ray = [[
|
||||
{
|
||||
"outbounds": [
|
||||
{ "protocol": "vmess", "tag": "v-vmess",
|
||||
"settings": { "vnext": [ { "address": "5.5.5.5", "port": 443, "users": [ { "id": "vid-1" } ] } ] },
|
||||
"streamSettings": { "network": "ws", "security": "tls", "tlsSettings": { "serverName": "ws.example.com" } } }
|
||||
]
|
||||
}
|
||||
]]
|
||||
|
||||
check("detect v2ray = json", parser.detect(v2ray) == "json")
|
||||
local r2 = parser.parse(v2ray)
|
||||
check("v2ray parses", r2 and r2.nodes ~= nil)
|
||||
check("v2ray 1 node", r2 and #r2.nodes == 1)
|
||||
check("v2ray uuid", r2 and r2.nodes[1] and r2.nodes[1].uuid == "vid-1")
|
||||
check("v2ray net ws", r2 and r2.nodes[1] and r2.nodes[1].net == "ws")
|
||||
check("v2ray sni", r2 and r2.nodes[1] and r2.nodes[1].sni == "ws.example.com")
|
||||
|
||||
-- ---------- Clash JSON ----------
|
||||
local clashjson = [[
|
||||
{ "proxies": [ { "name": "c-vmess", "type": "vmess", "server": "6.6.6.6", "port": 443, "uuid": "cu1" } ] }
|
||||
]]
|
||||
local r3 = parser.parse(clashjson)
|
||||
check("clash json parses", r3 and r3.nodes ~= nil and #r3.nodes == 1)
|
||||
check("clash json uuid", r3 and r3.nodes[1] and r3.nodes[1].uuid == "cu1")
|
||||
|
||||
-- ---------- Surge 配置 ----------
|
||||
local surge = [[
|
||||
[General]
|
||||
loglevel = notify
|
||||
|
||||
[Proxy]
|
||||
香港 = ss, 8.8.8.8, 8388, encrypt-method=aes-256-gcm, password=sspwd
|
||||
美西 = vmess, 9.9.9.9, 443, username=vm-uuid, tls=true, sni=vm.example.com, ws=true, ws-path=/wsp
|
||||
|
||||
[Proxy Group]
|
||||
PROXY = select, 香港, 美西
|
||||
]]
|
||||
check("detect surge = surge", parser.detect(surge) == "surge")
|
||||
local r4 = parser.parse(surge)
|
||||
check("surge parses", r4 and r4.nodes ~= nil)
|
||||
check("surge 2 nodes", r4 and #r4.nodes == 2)
|
||||
check("surge ss method", r4 and r4.nodes[1] and r4.nodes[1].method == "aes-256-gcm")
|
||||
check("surge ss pass", r4 and r4.nodes[1] and r4.nodes[1].password == "sspwd")
|
||||
check("surge vmess uuid", r4 and r4.nodes[2] and r4.nodes[2].uuid == "vm-uuid")
|
||||
check("surge vmess net ws", r4 and r4.nodes[2] and r4.nodes[2].net == "ws")
|
||||
|
||||
-- ---------- QX 配置 ----------
|
||||
local qx = [[
|
||||
[server_local]
|
||||
shadowsocks=10.0.0.1:8388, method=chacha20-ietf-poly1305, password=qxpwd, tag=QXSS
|
||||
vmess=10.0.0.2:443, method=none, password=qx-uuid, obfs=ws, obfs-host=qx.example.com, obfs-uri=/qx, tls-verification=true, tag=QXVMess
|
||||
|
||||
[policy]
|
||||
static=节点选择, QXSS, QXVMess, direct
|
||||
]]
|
||||
check("detect qx = surge", parser.detect(qx) == "surge")
|
||||
local r5 = parser.parse(qx)
|
||||
check("qx parses", r5 and r5.nodes ~= nil)
|
||||
check("qx 2 nodes", r5 and #r5.nodes == 2)
|
||||
check("qx ss pass", r5 and r5.nodes[1] and r5.nodes[1].password == "qxpwd")
|
||||
check("qx vmess uuid", r5 and r5.nodes[2] and r5.nodes[2].uuid == "qx-uuid")
|
||||
check("qx vmess net ws", r5 and r5.nodes[2] and r5.nodes[2].net == "ws")
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,266 @@
|
||||
-- parser_json_config_test.lua — JSON 配置解析单元测试
|
||||
-- 用法:lua5.1 tests/parser_json_config_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local json_parser = require("substore.parser_json_config")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- Sing-box JSON 解析 ----------
|
||||
local singbox_json = [[
|
||||
{
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "vmess-out",
|
||||
"type": "vmess",
|
||||
"server": "1.1.1.1",
|
||||
"server_port": 443,
|
||||
"uuid": "abc-123-def",
|
||||
"security": "tls",
|
||||
"alter_id": 0,
|
||||
"tls": {
|
||||
"server_name": "example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "vless-out",
|
||||
"type": "vless",
|
||||
"server": "2.2.2.2",
|
||||
"server_port": 443,
|
||||
"uuid": "vless-uuid-123",
|
||||
"flow": "xtls-rprx-vision",
|
||||
"tls": {
|
||||
"server_name": "example.org"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "trojan-out",
|
||||
"type": "trojan",
|
||||
"server": "3.3.3.3",
|
||||
"server_port": 443,
|
||||
"password": "trojan-pass",
|
||||
"tls": {
|
||||
"server_name": "trojan.example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "ss-out",
|
||||
"type": "shadowsocks",
|
||||
"server": "4.4.4.4",
|
||||
"server_port": 8388,
|
||||
"method": "aes-256-gcm",
|
||||
"password": "ss-pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
]]
|
||||
|
||||
local singbox_nodes = json_parser.parse_singbox_json(singbox_json)
|
||||
check("singbox parse result exists", singbox_nodes ~= nil)
|
||||
check("singbox node count", singbox_nodes and #singbox_nodes == 4)
|
||||
|
||||
if singbox_nodes and #singbox_nodes >= 1 then
|
||||
local n1 = singbox_nodes[1]
|
||||
check("singbox vmess name", n1.name == "vmess-out")
|
||||
check("singbox vmess proto", n1.proto == "vmess")
|
||||
check("singbox vmess server", n1.server == "1.1.1.1")
|
||||
check("singbox vmess port", n1.port == 443)
|
||||
check("singbox vmess uuid", n1.uuid == "abc-123-def")
|
||||
check("singbox vmess security", n1.security == "tls")
|
||||
check("singbox vmess sni", n1.sni == "example.com")
|
||||
end
|
||||
|
||||
if singbox_nodes and #singbox_nodes >= 2 then
|
||||
local n2 = singbox_nodes[2]
|
||||
check("singbox vless name", n2.name == "vless-out")
|
||||
check("singbox vless proto", n2.proto == "vless")
|
||||
check("singbox vless server", n2.server == "2.2.2.2")
|
||||
check("singbox vless port", n2.port == 443)
|
||||
check("singbox vless uuid", n2.uuid == "vless-uuid-123")
|
||||
end
|
||||
|
||||
if singbox_nodes and #singbox_nodes >= 3 then
|
||||
local n3 = singbox_nodes[3]
|
||||
check("singbox trojan name", n3.name == "trojan-out")
|
||||
check("singbox trojan proto", n3.proto == "trojan")
|
||||
check("singbox trojan server", n3.server == "3.3.3.3")
|
||||
check("singbox trojan port", n3.port == 443)
|
||||
check("singbox trojan password", n3.password == "trojan-pass")
|
||||
end
|
||||
|
||||
if singbox_nodes and #singbox_nodes >= 4 then
|
||||
local n4 = singbox_nodes[4]
|
||||
check("singbox ss name", n4.name == "ss-out")
|
||||
check("singbox ss proto", n4.proto == "shadowsocks")
|
||||
check("singbox ss server", n4.server == "4.4.4.4")
|
||||
check("singbox ss port", n4.port == 8388)
|
||||
check("singbox ss method", n4.method == "aes-256-gcm")
|
||||
check("singbox ss password", n4.password == "ss-pass")
|
||||
end
|
||||
|
||||
-- ---------- V2Ray JSON 解析 ----------
|
||||
local v2ray_json = [[
|
||||
{
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "vmess-out",
|
||||
"protocol": "vmess",
|
||||
"settings": {
|
||||
"vnext": [
|
||||
{
|
||||
"address": "5.5.5.5",
|
||||
"port": 443,
|
||||
"users": [
|
||||
{
|
||||
"id": "v2ray-vmess-uuid",
|
||||
"alterId": 0,
|
||||
"security": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "ws",
|
||||
"security": "tls",
|
||||
"tlsSettings": {
|
||||
"serverName": "v2ray.example.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "vless-out",
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"vnext": [
|
||||
{
|
||||
"address": "6.6.6.6",
|
||||
"port": 443,
|
||||
"users": [
|
||||
{
|
||||
"id": "v2ray-vless-uuid",
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "reality",
|
||||
"realitySettings": {
|
||||
"serverName": "reality.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]]
|
||||
|
||||
local v2ray_nodes = json_parser.parse_v2ray_json(v2ray_json)
|
||||
check("v2ray parse result exists", v2ray_nodes ~= nil)
|
||||
check("v2ray node count", v2ray_nodes and #v2ray_nodes == 2)
|
||||
|
||||
if v2ray_nodes and #v2ray_nodes >= 1 then
|
||||
local n1 = v2ray_nodes[1]
|
||||
check("v2ray vmess name", n1.name == "vmess-out")
|
||||
check("v2ray vmess proto", n1.proto == "vmess")
|
||||
check("v2ray vmess server", n1.server == "5.5.5.5")
|
||||
check("v2ray vmess port", n1.port == 443)
|
||||
check("v2ray vmess uuid", n1.uuid == "v2ray-vmess-uuid")
|
||||
check("v2ray vmess net", n1.net == "ws")
|
||||
end
|
||||
|
||||
if v2ray_nodes and #v2ray_nodes >= 2 then
|
||||
local n2 = v2ray_nodes[2]
|
||||
check("v2ray vless name", n2.name == "vless-out")
|
||||
check("v2ray vless proto", n2.proto == "vless")
|
||||
check("v2ray vless server", n2.server == "6.6.6.6")
|
||||
check("v2ray vless port", n2.port == 443)
|
||||
check("v2ray vless uuid", n2.uuid == "v2ray-vless-uuid")
|
||||
end
|
||||
|
||||
-- ---------- Clash JSON 解析 ----------
|
||||
local clash_json = [[
|
||||
{
|
||||
"proxies": [
|
||||
{
|
||||
"name": "ClashVMess",
|
||||
"type": "vmess",
|
||||
"server": "7.7.7.7",
|
||||
"port": 443,
|
||||
"uuid": "clash-vmess-uuid",
|
||||
"alterId": 0,
|
||||
"cipher": "auto",
|
||||
"network": "ws",
|
||||
"ws-opts": {
|
||||
"path": "/ws"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ClashVLESS",
|
||||
"type": "vless",
|
||||
"server": "8.8.8.8",
|
||||
"port": 443,
|
||||
"uuid": "clash-vless-uuid",
|
||||
"network": "tcp"
|
||||
},
|
||||
{
|
||||
"name": "ClashTrojan",
|
||||
"type": "trojan",
|
||||
"server": "9.9.9.9",
|
||||
"port": 443,
|
||||
"password": "clash-trojan-pass",
|
||||
"sni": "clash.example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]]
|
||||
|
||||
local clash_nodes = json_parser.parse_clash_json(clash_json)
|
||||
check("clash parse result exists", clash_nodes ~= nil)
|
||||
check("clash node count", clash_nodes and #clash_nodes == 3)
|
||||
|
||||
if clash_nodes and #clash_nodes >= 1 then
|
||||
local n1 = clash_nodes[1]
|
||||
check("clash vmess name", n1.name == "ClashVMess")
|
||||
check("clash vmess proto", n1.proto == "vmess")
|
||||
check("clash vmess server", n1.server == "7.7.7.7")
|
||||
check("clash vmess port", n1.port == 443)
|
||||
check("clash vmess uuid", n1.uuid == "clash-vmess-uuid")
|
||||
check("clash vmess net", n1.net == "ws")
|
||||
end
|
||||
|
||||
if clash_nodes and #clash_nodes >= 2 then
|
||||
local n2 = clash_nodes[2]
|
||||
check("clash vless name", n2.name == "ClashVLESS")
|
||||
check("clash vless proto", n2.proto == "vless")
|
||||
check("clash vless server", n2.server == "8.8.8.8")
|
||||
check("clash vless port", n2.port == 443)
|
||||
check("clash vless uuid", n2.uuid == "clash-vless-uuid")
|
||||
end
|
||||
|
||||
if clash_nodes and #clash_nodes >= 3 then
|
||||
local n3 = clash_nodes[3]
|
||||
check("clash trojan name", n3.name == "ClashTrojan")
|
||||
check("clash trojan proto", n3.proto == "trojan")
|
||||
check("clash trojan server", n3.server == "9.9.9.9")
|
||||
check("clash trojan port", n3.port == 443)
|
||||
check("clash trojan password", n3.password == "clash-trojan-pass")
|
||||
check("clash trojan sni", n3.sni == "clash.example.com")
|
||||
end
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,130 @@
|
||||
-- parse_local 表单模式测试:全字段透传 + 类型/别名修正
|
||||
package.path = "root/usr/share/?.lua;root/usr/share/?/init.lua;" .. package.path
|
||||
|
||||
local parser = require("substore.parser")
|
||||
local util = require("substore.util")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
local function check(name, cond)
|
||||
if cond then passed = passed + 1; print("PASS " .. name)
|
||||
else failed = failed + 1; print("FAIL " .. name) end
|
||||
end
|
||||
|
||||
-- 用户实际场景:Clash 风格 vmess ws 节点(经表单录入为 JSON)
|
||||
local form_json = util.json_encode({
|
||||
{
|
||||
type = "vmess",
|
||||
name = "test-vmess",
|
||||
server = "attwa10005.cdn.node.a.unicenter7.net",
|
||||
port = "30849",
|
||||
uuid = "b109403f-1b9c-3a10-9034-a5b1c2cacbc2",
|
||||
cipher = "auto",
|
||||
alterId = "0",
|
||||
net = "ws",
|
||||
path = "/009c250b-54f5-43a8-8b18-dccbb5d8c66a.y.live01.m3u8",
|
||||
["skip-cert-verify"] = "false",
|
||||
udp = "true",
|
||||
},
|
||||
})
|
||||
|
||||
local res, err = parser.parse_local(form_json, "form")
|
||||
check("form parse ok", res ~= nil)
|
||||
check("form format", res and res.format == "local-form")
|
||||
check("form node count", res and #res.nodes == 1)
|
||||
local n = res and res.nodes[1] or {}
|
||||
check("proto vmess", n.proto == "vmess")
|
||||
check("name", n.name == "test-vmess")
|
||||
check("server", n.server == "attwa10005.cdn.node.a.unicenter7.net")
|
||||
check("port coerced to number", n.port == 30849)
|
||||
check("uuid", n.uuid == "b109403f-1b9c-3a10-9034-a5b1c2cacbc2")
|
||||
check("cipher kept", n.cipher == "auto")
|
||||
check("method alias from cipher", n.method == "auto")
|
||||
check("alterId coerced", n.alterId == 0)
|
||||
check("net ws", n.net == "ws")
|
||||
check("path kept", n.path == "/009c250b-54f5-43a8-8b18-dccbb5d8c66a.y.live01.m3u8")
|
||||
check("skip-cert-verify bool false", n["skip-cert-verify"] == false)
|
||||
check("skip_cert_verify alias", n.skip_cert_verify == false)
|
||||
check("udp bool true", n.udp == true)
|
||||
|
||||
-- network 别名 → net
|
||||
local res2 = parser.parse_local(util.json_encode({
|
||||
{ type = "vless", server = "a.com", port = "443", uuid = "u", network = "ws", path = "/p" },
|
||||
}), "form")
|
||||
local n2 = res2 and res2.nodes[1] or {}
|
||||
check("network alias to net", n2.net == "ws")
|
||||
check("network key removed", n2.network == nil)
|
||||
check("path kept vless", n2.path == "/p")
|
||||
|
||||
-- ssr 参数别名
|
||||
local res3 = parser.parse_local(util.json_encode({
|
||||
{ type = "ssr", server = "b.com", port = "8388", password = "p", cipher = "aes-256-cfb",
|
||||
protocol = "auth_aes128_md5", obfs = "tls1.2_ticket_auth",
|
||||
["obfs-param"] = "op", ["protocol-param"] = "pp" },
|
||||
}), "form")
|
||||
local n3 = res3 and res3.nodes[1] or {}
|
||||
check("ssr obfs_param alias", n3.obfs_param == "op")
|
||||
check("ssr protocol_param alias", n3.protocol_param == "pp")
|
||||
|
||||
-- wireguard kebab-case 字段透传
|
||||
local res4 = parser.parse_local(util.json_encode({
|
||||
{ type = "wireguard", server = "c.com", port = "51820",
|
||||
["private-key"] = "pk", ["peer-public-key"] = "ppk" },
|
||||
}), "form")
|
||||
local n4 = res4 and res4.nodes[1] or {}
|
||||
check("wireguard private-key kept", n4["private-key"] == "pk")
|
||||
check("wireguard peer-public-key kept", n4["peer-public-key"] == "ppk")
|
||||
|
||||
-- 无名节点自动命名
|
||||
local res5 = parser.parse_local(util.json_encode({
|
||||
{ type = "trojan", server = "d.com", port = "443", password = "x" },
|
||||
}), "form")
|
||||
local n5 = res5 and res5.nodes[1] or {}
|
||||
check("auto name server:port", n5.name == "d.com:443")
|
||||
|
||||
-- 非 JSON 返回错误
|
||||
local res6, err6 = parser.parse_local("not json", "form")
|
||||
check("invalid json error", res6 == nil and err6 ~= nil)
|
||||
|
||||
-- hy2 混淆:表单透传 → URI 输出 → URI 解析回环
|
||||
local res7 = parser.parse_local(util.json_encode({
|
||||
{ type = "hysteria2", server = "hy2.com", port = "443", password = "pw",
|
||||
sni = "hy2.com", obfs = "salamander", ["obfs-password"] = "secret" },
|
||||
}), "form")
|
||||
local n7 = res7 and res7.nodes[1] or {}
|
||||
check("hy2 obfs passthrough", n7.obfs == "salamander")
|
||||
check("hy2 obfs-password passthrough", n7["obfs-password"] == "secret")
|
||||
|
||||
local output_uri = require("substore.output_uri")
|
||||
local hy2_link = output_uri.to_share_uri and output_uri.to_share_uri(n7) or nil
|
||||
check("hy2 uri generated", type(hy2_link) == "string" and hy2_link:match("^hysteria2://") ~= nil)
|
||||
check("hy2 uri has obfs", hy2_link and hy2_link:find("obfs=salamander", 1, true) ~= nil)
|
||||
check("hy2 uri has obfs-password", hy2_link and hy2_link:find("obfs%-password=secret") ~= nil)
|
||||
|
||||
-- URI 回读
|
||||
local res8 = hy2_link and parser.parse(hy2_link) or nil
|
||||
local n8 = res8 and res8.nodes and res8.nodes[1] or {}
|
||||
check("hy2 uri roundtrip obfs", n8.obfs == "salamander")
|
||||
check("hy2 uri roundtrip obfs-password", n8["obfs-password"] == "secret")
|
||||
|
||||
-- clash_meta / sing-box 输出含混淆
|
||||
local output_clash = require("substore.output_clash_meta")
|
||||
local output_singbox = require("substore.output_singbox")
|
||||
local cm = output_clash.generate and output_clash.generate({ n7 }) or nil
|
||||
check("clash_meta hy2 obfs", type(cm) == "string" and cm:find("obfs: salamander", 1, true) ~= nil)
|
||||
check("clash_meta hy2 obfs-password", type(cm) == "string" and cm:find("obfs%-password: secret") ~= nil)
|
||||
local sb = output_singbox.to_outbound and output_singbox.to_outbound(n7) or nil
|
||||
check("singbox hy2 obfs type", type(sb) == "table" and sb.obfs and sb.obfs.type == "salamander")
|
||||
check("singbox hy2 obfs password", type(sb) == "table" and sb.obfs and sb.obfs.password == "secret")
|
||||
|
||||
-- vmess headerType / tls 透传
|
||||
local res9 = parser.parse_local(util.json_encode({
|
||||
{ type = "vmess", server = "v.com", port = "80", uuid = "u", net = "tcp",
|
||||
headerType = "http", host = "a.com", tls = "tls" },
|
||||
}), "form")
|
||||
local n9 = res9 and res9.nodes[1] or {}
|
||||
check("vmess headerType passthrough", n9.headerType == "http")
|
||||
check("vmess tls passthrough", n9.tls == "tls")
|
||||
check("vmess type key removed", n9.type == nil)
|
||||
|
||||
print(string.format("%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,54 @@
|
||||
-- parser_local_link_test.lua — 局域网订阅链接解析单元测试
|
||||
-- 用法:lua5.1 tests/parser_local_link_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local parser = require("substore.parser")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- 局域网链接检测 ----------
|
||||
local local_url_1 = "http://192.168.5.15:31013/arthur/download/FeijiCloud?target=ClashMeta"
|
||||
local local_url_2 = "http://192.168.1.100:8080/api/subscription?name=MySub&target=Surge"
|
||||
local local_url_3 = "https://example.com/download" -- 非局域网,应该不识别
|
||||
|
||||
check("detect local link 192.168", parser.detect_local_link(local_url_1) ~= nil)
|
||||
check("detect local link 192.168.1", parser.detect_local_link(local_url_2) ~= nil)
|
||||
check("detect non-local link", parser.detect_local_link(local_url_3) == nil)
|
||||
|
||||
-- ---------- 解析局域网链接 ----------
|
||||
local result1 = parser.parse_local_link(local_url_1)
|
||||
check("parse_local_link host", result1 and result1.host == "192.168.5.15")
|
||||
check("parse_local_link port", result1 and result1.port == "31013")
|
||||
check("parse_local_link path", result1 and result1.path == "/arthur/download/FeijiCloud")
|
||||
check("parse_local_link target", result1 and result1.target == "ClashMeta")
|
||||
check("parse_local_link name", result1 and result1.name == "FeijiCloud")
|
||||
check("parse_local_link user", result1 and result1.user == "arthur")
|
||||
|
||||
local result2 = parser.parse_local_link(local_url_2)
|
||||
check("parse_local_link 2 host", result2 and result2.host == "192.168.1.100")
|
||||
check("parse_local_link 2 port", result2 and result2.port == "8080")
|
||||
check("parse_local_link 2 target", result2 and result2.target == "Surge")
|
||||
check("parse_local_link 2 name", result2 and result2.name == "MySub")
|
||||
|
||||
-- 测试带查询参数的链接
|
||||
local local_url_3 = "http://10.0.0.5:9000/sub?target=SingBox&name=TestSub&uid=user123"
|
||||
local result3 = parser.parse_local_link(local_url_3)
|
||||
check("parse_local_link 10.0.0", result3 and result3.host == "10.0.0.5")
|
||||
check("parse_local_link target SingBox", result3 and result3.target == "SingBox")
|
||||
check("parse_local_link name param", result3 and result3.name == "TestSub")
|
||||
check("parse_local_link user param", result3 and result3.user == "user123")
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,121 @@
|
||||
-- parser_yaml_test.lua — YAML 解析单元测试
|
||||
-- 用法:lua5.1 tests/parser_yaml_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local parser = require("substore.parser")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- YAML 检测 ----------
|
||||
local yaml_clash = [[
|
||||
proxies:
|
||||
- name: TestVMess
|
||||
type: vmess
|
||||
server: 1.1.1.1
|
||||
port: 443
|
||||
uuid: abc-123
|
||||
alterId: 0
|
||||
cipher: auto
|
||||
]]
|
||||
|
||||
check("detect yaml proxies", parser.detect(yaml_clash) == "yaml")
|
||||
|
||||
local yaml_outbounds = [[
|
||||
outbounds:
|
||||
- tag: direct
|
||||
type: http
|
||||
]]
|
||||
|
||||
check("detect yaml outbounds", parser.detect(yaml_outbounds) == "yaml")
|
||||
|
||||
-- ---------- YAML 解析 ----------
|
||||
local yaml_sample = [[
|
||||
proxies:
|
||||
- name: TestVMess
|
||||
type: vmess
|
||||
server: 1.1.1.1
|
||||
port: 443
|
||||
uuid: abc-123
|
||||
alterId: 0
|
||||
cipher: auto
|
||||
network: ws
|
||||
ws-opts:
|
||||
path: /ws
|
||||
- name: TestSS
|
||||
type: ss
|
||||
server: 2.2.2.2
|
||||
port: 8388
|
||||
cipher: aes-256-gcm
|
||||
password: pass123
|
||||
- name: TestTrojan
|
||||
type: trojan
|
||||
server: 3.3.3.3
|
||||
port: 443
|
||||
password: trojan-pass
|
||||
sni: example.com
|
||||
]]
|
||||
|
||||
local result = parser.parse(yaml_sample)
|
||||
check("parse yaml result exists", result ~= nil)
|
||||
check("parse yaml format", result and result.format == "yaml")
|
||||
check("parse yaml node count", result and #result.nodes == 3)
|
||||
|
||||
if result and #result.nodes >= 1 then
|
||||
local n1 = result.nodes[1]
|
||||
check("yaml vmess name", n1.name == "TestVMess")
|
||||
check("yaml vmess proto", n1.proto == "vmess")
|
||||
check("yaml vmess server", n1.server == "1.1.1.1")
|
||||
check("yaml vmess port", n1.port == 443)
|
||||
check("yaml vmess uuid", n1.uuid == "abc-123")
|
||||
check("yaml vmess net", n1.net == "ws")
|
||||
end
|
||||
|
||||
if result and #result.nodes >= 2 then
|
||||
local n2 = result.nodes[2]
|
||||
check("yaml ss name", n2.name == "TestSS")
|
||||
check("yaml ss proto", n2.proto == "shadowsocks")
|
||||
check("yaml ss server", n2.server == "2.2.2.2")
|
||||
check("yaml ss port", n2.port == 8388)
|
||||
check("yaml ss method", n2.method == "aes-256-gcm")
|
||||
check("yaml ss password", n2.password == "pass123")
|
||||
end
|
||||
|
||||
if result and #result.nodes >= 3 then
|
||||
local n3 = result.nodes[3]
|
||||
check("yaml trojan name", n3.name == "TestTrojan")
|
||||
check("yaml trojan proto", n3.proto == "trojan")
|
||||
check("yaml trojan server", n3.server == "3.3.3.3")
|
||||
check("yaml trojan port", n3.port == 443)
|
||||
check("yaml trojan password", n3.password == "trojan-pass")
|
||||
check("yaml trojan sni", n3.sni == "example.com")
|
||||
end
|
||||
|
||||
-- ---------- YAML parse_yaml 直接调用 ----------
|
||||
local yaml_simple = [[
|
||||
proxies:
|
||||
- name: SimpleNode
|
||||
type: vless
|
||||
server: 4.4.4.4
|
||||
port: 443
|
||||
uuid: vless-uuid
|
||||
]]
|
||||
|
||||
local nodes = parser.parse_yaml(yaml_simple)
|
||||
check("parse_yaml direct", nodes and #nodes == 1)
|
||||
check("parse_yaml direct name", nodes and nodes[1].name == "SimpleNode")
|
||||
check("parse_yaml direct proto", nodes and nodes[1].proto == "vless")
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,110 @@
|
||||
-- 简单的 YAML 解析逻辑验证(无依赖)
|
||||
-- 验证解析器的核心逻辑
|
||||
|
||||
-- 模拟 util.trim
|
||||
local function trim(s)
|
||||
return s:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
-- 模拟 split_lines
|
||||
local function split_lines(content)
|
||||
local out = {}
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
out[#out + 1] = line
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 测试 YAML 解析逻辑
|
||||
local function test_yaml_parsing()
|
||||
local yaml_sample = [[
|
||||
proxies:
|
||||
- name: TestVMess
|
||||
type: vmess
|
||||
server: 1.1.1.1
|
||||
port: 443
|
||||
uuid: abc-123
|
||||
- name: TestSS
|
||||
type: ss
|
||||
server: 2.2.2.2
|
||||
port: 8388
|
||||
cipher: aes-256-gcm
|
||||
password: pass123
|
||||
]]
|
||||
|
||||
local lines = split_lines(yaml_sample)
|
||||
local raw_nodes = {}
|
||||
local current_node = nil
|
||||
local in_proxies = false
|
||||
local proxies_indent = 0
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
local trimmed = trim(line)
|
||||
if trimmed == "" or trimmed:sub(1, 1) == "#" then
|
||||
elseif trimmed:match("^proxies:%s*$") or trimmed:match("^outbounds:%s*$") then
|
||||
in_proxies = true
|
||||
proxies_indent = line:match("^%s*") and #line:match("^%s*") or 0
|
||||
current_node = nil
|
||||
elseif in_proxies then
|
||||
local list_match = line:match("^%s*%-%s*(.*)$")
|
||||
if list_match then
|
||||
if current_node then
|
||||
raw_nodes[#raw_nodes + 1] = current_node
|
||||
end
|
||||
current_node = {}
|
||||
if list_match:match("^([^:]+):%s*(.+)$") then
|
||||
local k, v = list_match:match("^([^:]+):%s*(.+)$")
|
||||
current_node[k] = trim(v)
|
||||
end
|
||||
else
|
||||
local indent = line:match("^%s*") and #line:match("^%s*") or 0
|
||||
if current_node and indent > proxies_indent then
|
||||
local k, v = trimmed:match("^([^:]+):%s*(.+)$")
|
||||
if k then
|
||||
k = trim(k)
|
||||
v = trim(v)
|
||||
v = v:gsub('^["\'](.*)["\']$', '%1')
|
||||
current_node[k] = v
|
||||
end
|
||||
else
|
||||
if current_node then
|
||||
raw_nodes[#raw_nodes + 1] = current_node
|
||||
current_node = nil
|
||||
end
|
||||
in_proxies = false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if current_node then
|
||||
raw_nodes[#raw_nodes + 1] = current_node
|
||||
end
|
||||
|
||||
print("Parsed nodes: " .. #raw_nodes)
|
||||
for i, n in ipairs(raw_nodes) do
|
||||
print("Node " .. i .. ":")
|
||||
for k, v in pairs(n) do
|
||||
print(" " .. k .. " = " .. tostring(v))
|
||||
end
|
||||
end
|
||||
|
||||
-- 验证
|
||||
assert(#raw_nodes == 2, "Should have 2 nodes")
|
||||
assert(raw_nodes[1].name == "TestVMess", "First node name")
|
||||
assert(raw_nodes[1].type == "vmess", "First node type")
|
||||
assert(raw_nodes[1].server == "1.1.1.1", "First node server")
|
||||
assert(raw_nodes[1].port == "443", "First node port")
|
||||
assert(raw_nodes[1].uuid == "abc-123", "First node uuid")
|
||||
|
||||
assert(raw_nodes[2].name == "TestSS", "Second node name")
|
||||
assert(raw_nodes[2].type == "ss", "Second node type")
|
||||
assert(raw_nodes[2].server == "2.2.2.2", "Second node server")
|
||||
assert(raw_nodes[2].port == "8388", "Second node port")
|
||||
assert(raw_nodes[2].cipher == "aes-256-gcm", "Second node cipher")
|
||||
assert(raw_nodes[2].password == "pass123", "Second node password")
|
||||
|
||||
print("All tests passed!")
|
||||
end
|
||||
|
||||
test_yaml_parsing()
|
||||
@@ -0,0 +1,46 @@
|
||||
-- probe_test.lua — 节点探测模块单元测试
|
||||
-- 用法:lua5.1 tests/probe_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local probe = require("substore.probe")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
local function check(name, cond)
|
||||
if cond then passed = passed + 1; print("PASS " .. name)
|
||||
else failed = failed + 1; print("FAIL " .. name) end
|
||||
end
|
||||
|
||||
-- ---------- 解析函数 ----------
|
||||
check("parse_ping time", probe.parse_ping("64 bytes from 1.1.1.1: seq=0 ttl=54 time=12.345 ms") == 12.345)
|
||||
check("parse_ping loss", probe.parse_ping("100% packet loss") == nil)
|
||||
check("parse_ping empty", probe.parse_ping("") == nil)
|
||||
check("parse_delta ok", math.abs((probe.parse_delta("100.00 100.05") or 0) - 50) < 1)
|
||||
check("parse_delta empty", probe.parse_delta("") == nil)
|
||||
check("parse_delta single", probe.parse_delta("100.00") == nil)
|
||||
|
||||
-- ---------- 注入防护(在启动子进程前被拒绝,返回 nil)----------
|
||||
check("ping empty host", probe.ping("") == nil)
|
||||
check("ping injection", probe.ping("1.1.1.1; rm -rf /") == nil)
|
||||
check("tcping bad port", probe.tcping("1.1.1.1", "abc") == nil)
|
||||
check("tcping port overflow", probe.tcping("1.1.1.1", 99999) == nil)
|
||||
check("url injection", probe.url_test("example.com; touch /tmp/x", 80) == nil)
|
||||
|
||||
-- ---------- 本地回环 ----------
|
||||
check("ping loopback numeric", type(probe.ping("127.0.0.1")) == "number")
|
||||
check("tcping closed port nil", probe.tcping("127.0.0.1", 1) == nil)
|
||||
check("url closed port nil", probe.url_test("127.0.0.1", 1) == nil)
|
||||
|
||||
-- ---------- 批量探测结果结构 ----------
|
||||
local r = probe.probe({
|
||||
{ name = "a", server = "127.0.0.1", port = 80 },
|
||||
{ name = "bad", server = "bad host", port = 80 },
|
||||
{ name = "b", server = "127.0.0.1", port = 1 },
|
||||
}, "ping")
|
||||
check("probe returns 3 results", #r == 3)
|
||||
check("probe name preserved", r[1].name == "a" and r[2].name == "bad" and r[3].name == "b")
|
||||
check("probe invalid host latency nil", r[2].latency == nil)
|
||||
check("probe shape fields", r[1].server == "127.0.0.1" and r[1].port == 80)
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,152 @@
|
||||
-- protocol_support_test.lua — hysteria2 / tuic / wireguard 导入、JSON 配置导入放开、Surge 家族输出单元测试
|
||||
-- 用法:lua5.1 tests/protocol_support_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local parser = require("substore.parser")
|
||||
local parser_json_config = require("substore.parser_json_config")
|
||||
local output = require("substore.output")
|
||||
local output_uri = require("substore.output_uri")
|
||||
local util = require("substore.util")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- hysteria2:// 分享链接导入 ----------
|
||||
local hy2 = parser.parse_uri("hysteria2://my-secret@1.2.3.4:443/?sni=sni.example.com&insecure=1#HY2节点")
|
||||
check("hy2 parses", hy2 ~= nil)
|
||||
check("hy2 proto", hy2 and hy2.proto == "hysteria2")
|
||||
check("hy2 server", hy2 and hy2.server == "1.2.3.4")
|
||||
check("hy2 port", hy2 and hy2.port == 443)
|
||||
check("hy2 password", hy2 and hy2.password == "my-secret")
|
||||
check("hy2 sni", hy2 and hy2.sni == "sni.example.com")
|
||||
check("hy2 insecure", hy2 and hy2.insecure == "1")
|
||||
check("hy2 name", hy2 and hy2.name == "HY2节点")
|
||||
|
||||
-- ---------- tuic:// 分享链接导入 ----------
|
||||
local tuic = parser.parse_uri("tuic://uuid-1234:tuic-pass@5.6.7.8:8443/?congestion_control=bbr&alpn=h3&sni=t.example.com#TUIC节点")
|
||||
check("tuic parses", tuic ~= nil)
|
||||
check("tuic proto", tuic and tuic.proto == "tuic")
|
||||
check("tuic server", tuic and tuic.server == "5.6.7.8")
|
||||
check("tuic port", tuic and tuic.port == 8443)
|
||||
check("tuic uuid", tuic and tuic.uuid == "uuid-1234")
|
||||
check("tuic password", tuic and tuic.password == "tuic-pass")
|
||||
check("tuic congestion_control", tuic and tuic.congestion_control == "bbr")
|
||||
check("tuic alpn", tuic and tuic.alpn == "h3")
|
||||
check("tuic sni", tuic and tuic.sni == "t.example.com")
|
||||
check("tuic name", tuic and tuic.name == "TUIC节点")
|
||||
|
||||
-- ---------- wireguard:// 导入(固定 base64 外部样本,非本实现生成) ----------
|
||||
-- base64 内容:{"server":"10.0.0.9","port":"51820","private-key":"privkey123","peer-public-key":"pubkey456","preshared-key":"psk789","mtu":"1420"}
|
||||
local wg_uri = "wireguard://eyJzZXJ2ZXIiOiIxMC4wLjAuOSIsInBvcnQiOiI1MTgyMCIsInByaXZhdGUta2V5IjoicHJpdmtleTEyMyIsInBlZXItcHVibGljLWtleSI6InB1YmtleTQ1NiIsInByZXNoYXJlZC1rZXkiOiJwc2s3ODkiLCJtdHUiOiIxNDIwIn0=#WG节点"
|
||||
check("detect wg = uri", parser.detect(wg_uri) == "uri")
|
||||
local wg = parser.parse_uri(wg_uri)
|
||||
check("wg parses", wg ~= nil)
|
||||
check("wg proto", wg and wg.proto == "wireguard")
|
||||
check("wg server", wg and wg.server == "10.0.0.9")
|
||||
check("wg port", wg and wg.port == 51820)
|
||||
check("wg private-key", wg and wg["private-key"] == "privkey123")
|
||||
check("wg peer-public-key", wg and wg["peer-public-key"] == "pubkey456")
|
||||
check("wg preshared-key", wg and wg["preshared-key"] == "psk789")
|
||||
check("wg mtu", wg and wg.mtu == 1420)
|
||||
check("wg name", wg and wg.name == "WG节点")
|
||||
|
||||
-- ---------- 订阅(URI 列表 / base64)含 hy2 / tuic / wg ----------
|
||||
local sub = "hysteria2://p@1.1.1.1:8443#A\ntuic://u:p@2.2.2.2:8443#B\nvless://uuid1@3.3.3.3:443#C"
|
||||
local res = parser.parse(sub)
|
||||
check("sub format uri", res and res.format == "uri")
|
||||
check("sub 3 nodes", res and #res.nodes == 3)
|
||||
|
||||
local b64sub = util.base64_encode("hysteria2://p@1.1.1.1:8443#A\ntuic://u:p@2.2.2.2:8443#B")
|
||||
local res2 = parser.parse(b64sub)
|
||||
check("base64 sub 2 nodes", res2 and #res2.nodes == 2)
|
||||
check("base64 sub proto hy2", res2 and res2.nodes[1] and res2.nodes[1].proto == "hysteria2")
|
||||
|
||||
-- ---------- URI 输出(Shadowrocket / V2Ray URI)含 hy2 / tuic / wg ----------
|
||||
local hy2node = { proto = "hysteria2", name = "HY", server = "1.1.1.1", port = 443, password = "p", sni = "s.example.com" }
|
||||
local tuicnode = { proto = "tuic", name = "TC", server = "2.2.2.2", port = 8443, uuid = "u", password = "p" }
|
||||
local wgnode = { proto = "wireguard", name = "WG", server = "10.0.0.9", port = 51820,
|
||||
["private-key"] = "pk", ["peer-public-key"] = "ppk", ["preshared-key"] = "psk" }
|
||||
|
||||
local uri_out = output.generate({ hy2node, tuicnode, wgnode }, "v2rayuri")
|
||||
check("uri has hysteria2", uri_out and uri_out:find("hysteria2://") ~= nil)
|
||||
check("uri has tuic", uri_out and uri_out:find("tuic://") ~= nil)
|
||||
check("uri has wireguard", uri_out and uri_out:find("wireguard://") ~= nil)
|
||||
|
||||
local rocket = output.generate({ hy2node, tuicnode, wgnode }, "shadowrocket")
|
||||
local decoded = util.base64_decode(rocket)
|
||||
check("shadowrocket has hysteria2", decoded:find("hysteria2://") ~= nil)
|
||||
check("shadowrocket has wireguard", decoded:find("wireguard://") ~= nil)
|
||||
|
||||
-- ---------- wireguard 回环(输出 -> 解析) ----------
|
||||
local wg_round = parser.parse_uri(uri_out:match("wireguard://[^\n]*"))
|
||||
check("wg roundtrip proto", wg_round and wg_round.proto == "wireguard")
|
||||
check("wg roundtrip server", wg_round and wg_round.server == "10.0.0.9")
|
||||
check("wg roundtrip private", wg_round and wg_round["private-key"] == "pk")
|
||||
check("wg roundtrip peer", wg_round and wg_round["peer-public-key"] == "ppk")
|
||||
|
||||
-- ---------- JSON 配置导入放开:sing-box JSON ----------
|
||||
local singbox = [[
|
||||
{
|
||||
"outbounds": [
|
||||
{ "type": "hysteria2", "tag": "sb-hy2", "server": "1.1.1.1", "server_port": 443, "password": "hy2pw", "tls": { "server_name": "h.sni.com" } },
|
||||
{ "type": "tuic", "tag": "sb-tuic", "server": "2.2.2.2", "server_port": 8443, "uuid": "sb-uuid", "password": "sb-tuicpw", "congestion_control": "bbr" },
|
||||
{ "type": "wireguard", "tag": "sb-wg", "server": "3.3.3.3", "server_port": 51820, "private_key": "sb-priv", "peer_public_key": "sb-pub", "preshared_key": "sb-psk", "mtu": 1400 },
|
||||
{ "type": "socks", "tag": "sb-socks", "server": "4.4.4.4", "server_port": 1080, "username": "suser", "password": "spass" }
|
||||
]
|
||||
}
|
||||
]]
|
||||
local sbnodes = parser_json_config.parse_singbox_json(singbox)
|
||||
check("singbox 4 nodes", sbnodes and #sbnodes == 4)
|
||||
check("singbox hy2", sbnodes and sbnodes[1] and sbnodes[1].proto == "hysteria2" and sbnodes[1].password == "hy2pw" and sbnodes[1].sni == "h.sni.com")
|
||||
check("singbox tuic", sbnodes and sbnodes[2] and sbnodes[2].proto == "tuic" and sbnodes[2].uuid == "sb-uuid" and sbnodes[2].password == "sb-tuicpw")
|
||||
check("singbox wg kebab", sbnodes and sbnodes[3] and sbnodes[3].proto == "wireguard" and sbnodes[3]["private-key"] == "sb-priv" and sbnodes[3]["peer-public-key"] == "sb-pub" and sbnodes[3]["preshared-key"] == "sb-psk")
|
||||
check("singbox socks", sbnodes and sbnodes[4] and sbnodes[4].proto == "socks" and sbnodes[4].username == "suser" and sbnodes[4].password == "spass")
|
||||
|
||||
-- ---------- JSON 配置导入放开:Clash JSON(含 ssr) ----------
|
||||
local clashjson = [[
|
||||
{
|
||||
"proxies": [
|
||||
{ "name": "c-ssr", "type": "ssr", "server": "1.1.1.1", "port": 8388, "cipher": "aes-128-cfb", "password": "ssrpw", "protocol": "auth_sha1_v4", "obfs": "http_simple", "obfs-param": "obfshost.com" },
|
||||
{ "name": "c-hy2", "type": "hysteria2", "server": "2.2.2.2", "port": 443, "password": "hy2pw", "sni": "h2.example.com" },
|
||||
{ "name": "c-tuic", "type": "tuic", "server": "3.3.3.3", "port": 8443, "uuid": "c-uuid", "password": "c-tuicpw" },
|
||||
{ "name": "c-wg", "type": "wireguard", "server": "4.4.4.4", "port": 51820, "private-key": "c-priv", "peer-public-key": "c-pub", "preshared-key": "c-psk" },
|
||||
{ "name": "c-socks", "type": "socks5", "server": "5.5.5.5", "port": 1080, "username": "cu", "password": "cp" }
|
||||
]
|
||||
}
|
||||
]]
|
||||
local cnodes = parser_json_config.parse_clash_json(clashjson)
|
||||
check("clash json 5 nodes", cnodes and #cnodes == 5)
|
||||
check("clash json ssr", cnodes and cnodes[1] and cnodes[1].proto == "ssr" and cnodes[1].method == "aes-128-cfb" and cnodes[1].protocol == "auth_sha1_v4" and cnodes[1].obfs == "http_simple" and cnodes[1].obfs_param == "obfshost.com")
|
||||
check("clash json hy2", cnodes and cnodes[2] and cnodes[2].proto == "hysteria2" and cnodes[2].password == "hy2pw")
|
||||
check("clash json tuic", cnodes and cnodes[3] and cnodes[3].proto == "tuic" and cnodes[3].uuid == "c-uuid")
|
||||
check("clash json wg", cnodes and cnodes[4] and cnodes[4].proto == "wireguard" and cnodes[4]["private-key"] == "c-priv" and cnodes[4]["peer-public-key"] == "c-pub")
|
||||
check("clash json socks5->socks", cnodes and cnodes[5] and cnodes[5].proto == "socks" and cnodes[5].username == "cu" and cnodes[5].password == "cp")
|
||||
|
||||
-- ---------- Surge 家族:tuic 输出 / wireguard 丢弃 ----------
|
||||
local surge = output.generate({ tuicnode, wgnode }, "surge")
|
||||
check("surge has tuic line", surge and surge:find("tuic, 2%.2%.2%.2, 8443") ~= nil)
|
||||
check("surge tuic username", surge and surge:find("username=u") ~= nil)
|
||||
check("surge drops wireguard", surge and surge:find("10%.0%.0%.9") == nil and surge:find("wireguard") == nil)
|
||||
|
||||
local loon = output.generate({ wgnode }, "loon")
|
||||
check("loon drops wireguard too", loon and loon:find("wireguard") == nil)
|
||||
|
||||
-- ---------- sing-box 输出 wireguard 使用 kebab 字段(导入后可正确输出) ----------
|
||||
local sb_out = output.generate({ wgnode }, "singbox")
|
||||
local sb_decoded = util.json_decode(sb_out)
|
||||
check("singbox wg private_key", sb_decoded and sb_decoded.outbounds[1] and sb_decoded.outbounds[1].private_key == "pk")
|
||||
check("singbox wg peer_public_key", sb_decoded and sb_decoded.outbounds[1] and sb_decoded.outbounds[1].peer_public_key == "ppk")
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,125 @@
|
||||
-- run_tests.lua — 纯 Lua 核心库单元测试(在目标设备或装有 Lua 5.1 的环境运行)
|
||||
-- 用法:lua5.1 tests/run_tests.lua
|
||||
-- 说明:本仓库开发环境无 Lua 运行时,脚本未在本地执行;请在有 Lua 的设备/环境验证。
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local util = require("substore.util")
|
||||
local parser = require("substore.parser")
|
||||
local node = require("substore.node")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
local function eq(a, b)
|
||||
if type(a) ~= type(b) then return false end
|
||||
if type(a) == "table" then
|
||||
for k, v in pairs(a) do if not eq(v, b[k]) then return false end end
|
||||
for k in pairs(b) do if a[k] == nil then return false end end
|
||||
return true
|
||||
end
|
||||
return a == b
|
||||
end
|
||||
|
||||
-- ---------- base64 ----------
|
||||
check("b64 decode hello", util.base64_decode("aGVsbG8=") == "hello")
|
||||
check("b64 encode hello", util.base64_encode("hello") == "aGVsbG8=")
|
||||
check("b64 roundtrip unicode", util.base64_decode(util.base64_encode("机场节点中文")) == "机场节点中文")
|
||||
check("b64 paddingless", util.base64_decode("aGVsbG8") == "hello")
|
||||
check("b64 skip whitespace", util.base64_decode("aGVs\nbG8=") == "hello")
|
||||
|
||||
-- ---------- JSON ----------
|
||||
local sample = { _seq = 2, items = { s1 = { name = "机场", url = "https://x.test/sub", node_count = 3, enabled = true, last_update = nil } } }
|
||||
local encoded = util.json_encode(sample)
|
||||
local decoded = util.json_decode(encoded)
|
||||
check("json roundtrip", eq(decoded, sample))
|
||||
check("json decode unicode escape", util.json_decode('{"name":"\\u4e2d\\u6587"}').name == "中文")
|
||||
check("json array", #util.json_decode("[1,2,3]") == 3)
|
||||
check("json nested", util.json_decode('{"a":{"b":[true,false,null,1.5]}}').a.b[4] == 1.5)
|
||||
|
||||
-- ---------- URL ----------
|
||||
local h1, p1 = util.split_hostport("1.2.3.4:443")
|
||||
check("split_hostport v4", h1 == "1.2.3.4" and p1 == "443")
|
||||
local h2, p2 = util.split_hostport("[::1]:80")
|
||||
check("split_hostport v6", h2 == "::1" and p2 == "80")
|
||||
check("url_extract", util.url_extract("?type=tcp&security=none", "security") == "none")
|
||||
check("url_decode", util.url_decode("%E6%9C%BA%E5%9C%BA") == "机场")
|
||||
|
||||
-- ---------- node.normalize ----------
|
||||
check("normalize ss alias", node.normalize({ proto = "ss", name = "x", server = "h", port = 1 }).proto == "shadowsocks")
|
||||
local nm = node.normalize({ proto = "vmess", server = "h", port = 443 })
|
||||
check("normalize default net", nm.net == "tcp")
|
||||
check("normalize default name", node.normalize({ proto = "vless", server = "h" }).name == "h:")
|
||||
|
||||
-- ---------- parser.parse_uri ----------
|
||||
local vmess_json = util.json_encode({ add = "1.1.1.1", port = 443, id = "uuid1", net = "ws", scy = "auto", ps = "测试节点" })
|
||||
local n1 = parser.parse_uri("vmess://" .. util.base64_encode(vmess_json))
|
||||
check("vmess classic", n1 and n1.server == "1.1.1.1" and n1.port == 443 and n1.uuid == "uuid1" and n1.net == "ws")
|
||||
check("vmess name", n1 and n1.name == "测试节点")
|
||||
|
||||
local n2 = parser.parse_uri("vless://uuid1@1.1.1.1:443?type=ws&security=tls&sni=example.com#VLESS")
|
||||
check("vless", n2 and n2.proto == "vless" and n2.uuid == "uuid1" and n2.server == "1.1.1.1" and n2.net == "ws" and n2.sni == "example.com" and n2.name == "VLESS")
|
||||
|
||||
local n3 = parser.parse_uri("trojan://pass1@2.2.2.2:443?security=tls&sni=abc.com#TROJAN")
|
||||
check("trojan", n3 and n3.password == "pass1" and n3.server == "2.2.2.2" and n3.sni == "abc.com")
|
||||
|
||||
local n4 = parser.parse_uri("ss://" .. util.base64_encode("aes-256-gcm:password1") .. "@3.3.3.3:8388#SS")
|
||||
check("ss b64-userinfo", n4 and n4.method == "aes-256-gcm" and n4.password == "password1" and n4.server == "3.3.3.3" and n4.port == 8388)
|
||||
|
||||
local n5 = parser.parse_uri("ss://aes-256-gcm:password2@4.4.4.4:8388#SS2")
|
||||
check("ss plain", n5 and n5.method == "aes-256-gcm" and n5.password == "password2" and n5.server == "4.4.4.4")
|
||||
|
||||
local n6 = parser.parse_uri("ss://" .. util.base64_encode("aes-256-gcm:password3") .. "@5.5.5.5:8388/?plugin=obfs-local%3Bobfs%3Dhttp")
|
||||
check("ss plugin", n6 and n6.plugin ~= nil and n6.server == "5.5.5.5")
|
||||
|
||||
-- ss://base64(whole "method:password@host:port")#name
|
||||
local n7 = parser.parse_uri("ss://" .. util.base64_encode("chacha20-ietf-poly1305:passw@6.6.6.6:443") .. "#SSW")
|
||||
check("ss b64-whole", n7 and n7.method == "chacha20-ietf-poly1305" and n7.password == "passw" and n7.server == "6.6.6.6" and n7.port == 443)
|
||||
|
||||
check("unsupported proto", parser.parse_uri("foobar://x") == nil)
|
||||
|
||||
-- ---------- parser.detect / parse ----------
|
||||
check("detect base64", parser.detect("aGVsbG8vZGVjb2RlZC1zc3Nzc3Nzc3M=") == "base64")
|
||||
check("detect uri", parser.detect("vless://uuid1@1.1.1.1:443#x") == "uri")
|
||||
check("detect json", parser.detect('{"server":"1.1.1.1","port":443}') == "json")
|
||||
check("detect unknown", parser.detect("garbage content here") == "unknown")
|
||||
|
||||
local uri_sub = "vless://uuid1@1.1.1.1:443#A\ntrojan://pass@2.2.2.2:443#B\nss://aes-128-gcm:pass@3.3.3.3:8388#C"
|
||||
local res = parser.parse(uri_sub)
|
||||
check("parse uri sub count", res and #res.nodes == 3)
|
||||
check("parse uri sub format", res and res.format == "uri")
|
||||
|
||||
-- base64 订阅:内容是若干 URI 的 base64
|
||||
local plain = "vless://uuid1@1.1.1.1:443#A\nss://aes-128-gcm:pass@2.2.2.2:8388#B"
|
||||
local res2 = parser.parse(util.base64_encode(plain))
|
||||
check("parse base64 sub count", res2 and #res2.nodes == 2)
|
||||
check("parse base64 sub format", res2 and res2.format == "base64")
|
||||
|
||||
-- base64url(URL-safe base64:- _ 替代 + /,无 padding)订阅
|
||||
check("detect base64url literal", parser.detect("eyJ-abc_def") == "base64")
|
||||
local vmess_link = "vmess://" .. util.base64_encode(util.json_encode({ v = "2", ps = "B64U", add = "9.9.9.9", port = "443", id = "u-1", aid = "0", net = "tcp" }))
|
||||
local b64u_sub = util.base64_url_encode(vmess_link .. "\nvless://uuid2@8.8.8.8:443#V")
|
||||
check("detect base64url sub", parser.detect(b64u_sub) == "base64")
|
||||
local b64u_res = parser.parse(b64u_sub)
|
||||
check("parse base64url count", b64u_res and #b64u_res.nodes == 2)
|
||||
check("parse base64url proto", b64u_res and b64u_res.nodes[1] and b64u_res.nodes[1].proto == "vmess")
|
||||
check("parse base64url server", b64u_res and b64u_res.nodes[1] and b64u_res.nodes[1].server == "9.9.9.9")
|
||||
|
||||
-- UTF-8 BOM 前缀的订阅
|
||||
local bom_sub = "\239\187\191" .. util.base64_encode(vmess_link)
|
||||
check("detect BOM prefix", parser.detect(bom_sub) == "base64")
|
||||
local bom_res = parser.parse(bom_sub)
|
||||
check("parse BOM count", bom_res and #bom_res.nodes == 1)
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,99 @@
|
||||
-- ssr_test.lua — SSR(ShadowsocksR)解析与输出单元测试
|
||||
-- 用法:lua5.1 tests/ssr_test.lua
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local util = require("substore.util")
|
||||
local parser = require("substore.parser")
|
||||
local output = require("substore.output")
|
||||
local output_uri = require("substore.output_uri")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- 独立构造的已知 SSR 分享链接(由 Python base64 生成,非本实现生成,避免循环验证):
|
||||
-- server=127.0.0.1 port=8388 protocol=auth_aes128_md5 method=aes-128-cfb obfs=http_simple
|
||||
-- password=test-password obfsparam=download.windowsupdate.com remarks=测试节点 group=测试组
|
||||
local link = "ssr://MTI3LjAuMC4xOjgzODg6YXV0aF9hZXMxMjhfbWQ1OmFlcy0xMjgtY2ZiOmh0dHBfc2ltcGxlOmRHVnpkQzF3WVhOemQyOXlaQT09Lz9vYmZzcGFyYW09Wkc5M2JteHZZV1F1ZDJsdVpHOTNjM1Z3WkdGMFpTNWpiMjAmcmVtYXJrcz01cldMNkstVjZJcUM1NEs1Jmdyb3VwPTVyV0w2Sy1WNTd1RQ=="
|
||||
|
||||
check("detect ssr = uri", parser.detect(link) == "uri")
|
||||
local n = parser.parse_uri(link)
|
||||
check("ssr parses", n ~= nil)
|
||||
check("ssr proto", n and n.proto == "ssr")
|
||||
check("ssr server", n and n.server == "127.0.0.1")
|
||||
check("ssr port", n and n.port == 8388)
|
||||
check("ssr protocol", n and n.protocol == "auth_aes128_md5")
|
||||
check("ssr method", n and n.method == "aes-128-cfb")
|
||||
check("ssr obfs", n and n.obfs == "http_simple")
|
||||
check("ssr password", n and n.password == "test-password")
|
||||
check("ssr obfs_param", n and n.obfs_param == "download.windowsupdate.com")
|
||||
check("ssr name", n and n.name == "测试节点")
|
||||
check("ssr group", n and n.group == "测试组")
|
||||
|
||||
-- ---------- 订阅解析(URI 列表含 ssr) ----------
|
||||
local res = parser.parse(link .. "\nvless://uuid1@1.1.1.1:443#V")
|
||||
check("parse ssr sub count", res and res.nodes and #res.nodes == 2)
|
||||
check("parse ssr sub proto", res and res.nodes[1] and res.nodes[1].proto == "ssr")
|
||||
|
||||
-- ---------- ssr:// 分享链接回环 ----------
|
||||
local re = output_uri.to_ssr_uri(n)
|
||||
check("to_ssr_uri returns link", re and re:sub(1, 6) == "ssr://")
|
||||
local n2 = parser.parse_uri(re)
|
||||
check("ssr roundtrip proto", n2 and n2.proto == "ssr")
|
||||
check("ssr roundtrip server", n2 and n2.server == "127.0.0.1")
|
||||
check("ssr roundtrip port", n2 and n2.port == 8388)
|
||||
check("ssr roundtrip method", n2 and n2.method == "aes-128-cfb")
|
||||
check("ssr roundtrip password", n2 and n2.password == "test-password")
|
||||
check("ssr roundtrip protocol", n2 and n2.protocol == "auth_aes128_md5")
|
||||
check("ssr roundtrip obfs", n2 and n2.obfs == "http_simple")
|
||||
check("ssr roundtrip obfs_param", n2 and n2.obfs_param == "download.windowsupdate.com")
|
||||
check("ssr roundtrip name", n2 and n2.name == "测试节点")
|
||||
check("ssr roundtrip group", n2 and n2.group == "测试组")
|
||||
|
||||
-- ---------- Clash.Meta / Mihomo 输出 ----------
|
||||
local cm = output.generate({ n }, "clashmeta")
|
||||
check("clashmeta type ssr", cm and cm:find("type: ssr") ~= nil)
|
||||
check("clashmeta cipher", cm and cm:find("cipher: aes%-128%-cfb") ~= nil)
|
||||
check("clashmeta password", cm and cm:find("password: test%-password") ~= nil)
|
||||
check("clashmeta protocol", cm and cm:find("protocol: auth_aes128_md5") ~= nil)
|
||||
check("clashmeta obfs", cm and cm:find("obfs: http_simple") ~= nil)
|
||||
check("clashmeta obfs-param", cm and cm:find("obfs%-param: download%.windowsupdate%.com") ~= nil)
|
||||
|
||||
-- ---------- Shadowrocket(base64 URI)输出包含 ssr:// ----------
|
||||
local shr = output.generate({ n }, "shadowrocket")
|
||||
check("shadowrocket has ssr", shr and util.base64_decode(shr):find("ssr://") ~= nil)
|
||||
|
||||
-- ---------- Loon 保留 SSR,Surge 丢弃 ----------
|
||||
local loon = output.generate({ n }, "loon")
|
||||
check("loon has ssr", loon and loon:find("ssr, 127%.0%.0%.1, 8388") ~= nil)
|
||||
check("loon encrypt-method", loon and loon:find("encrypt%-method=aes%-128%-cfb") ~= nil)
|
||||
check("loon protocol", loon and loon:find("protocol=auth_aes128_md5") ~= nil)
|
||||
local surge = output.generate({ n }, "surge")
|
||||
check("surge drops ssr", surge and surge:find("127%.0%.0%.1") == nil)
|
||||
|
||||
-- ---------- sing-box / V2Ray 丢弃 SSR ----------
|
||||
local sb = output.generate({ n }, "singbox")
|
||||
check("singbox drops ssr", sb and util.json_decode(sb) and #util.json_decode(sb).outbounds == 0)
|
||||
local v2 = output.generate({ n }, "v2ray")
|
||||
check("v2ray drops ssr", v2 and util.json_decode(v2) and #util.json_decode(v2).outbounds == 0)
|
||||
|
||||
-- ---------- 最小 ssr(无参数,默认 origin/plain) ----------
|
||||
local minimal = parser.parse_uri("ssr://" .. util.base64_encode("1.2.3.4:80:origin:aes-256-cfb:plain:" .. util.base64_encode("p")))
|
||||
check("ssr minimal", minimal and minimal.proto == "ssr" and minimal.server == "1.2.3.4" and minimal.password == "p")
|
||||
|
||||
-- ---------- base64url 参数解码回退 ----------
|
||||
local b64u = util.base64_url_encode("香港节点")
|
||||
check("base64url bare", b64u == "6aaZ5riv6IqC54K5")
|
||||
check("base64url roundtrip", util.base64_url_decode(b64u) == "香港节点")
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
Reference in new issue
Block a user