From 85290de2572f505c72edc1be5e03fdbe894bdbcd Mon Sep 17 00:00:00 2001 From: Aethersailor <22260104+Aethersailor@users.noreply.github.com> Date: Mon, 25 May 2026 17:21:58 +0800 Subject: [PATCH] feat(sub): add explain mode smoke coverage --- .github/actions/smoke-docker-image/action.yml | 1 + .../actions/smoke-linux-artifact/action.yml | 1 + .../actions/smoke-windows-artifact/action.yml | 2 + scripts/run-subconverter-smoke.py | 141 +++++++++++ src/handler/interfaces.cpp | 225 ++++++++++++++++++ tests/snapshots/README.md | 21 ++ 6 files changed, 391 insertions(+) create mode 100644 scripts/run-subconverter-smoke.py create mode 100644 tests/snapshots/README.md diff --git a/.github/actions/smoke-docker-image/action.yml b/.github/actions/smoke-docker-image/action.yml index 031133e..338644a 100644 --- a/.github/actions/smoke-docker-image/action.yml +++ b/.github/actions/smoke-docker-image/action.yml @@ -40,6 +40,7 @@ runs: for _ in $(seq 1 30); do if curl -fsS "$URL" >/tmp/subconverter-smoke.out; then head -c 200 /tmp/subconverter-smoke.out + python3 scripts/run-subconverter-smoke.py --base-url "http://127.0.0.1:${HOST_PORT}" exit 0 fi diff --git a/.github/actions/smoke-linux-artifact/action.yml b/.github/actions/smoke-linux-artifact/action.yml index 8816f08..585e11d 100644 --- a/.github/actions/smoke-linux-artifact/action.yml +++ b/.github/actions/smoke-linux-artifact/action.yml @@ -42,6 +42,7 @@ runs: for _ in $(seq 1 30); do if curl -fsS "$URL" >"$WORKDIR/response.out"; then head -c 200 "$WORKDIR/response.out" + python3 "$GITHUB_WORKSPACE/scripts/run-subconverter-smoke.py" --base-url "http://127.0.0.1:${PORT}" exit 0 fi diff --git a/.github/actions/smoke-windows-artifact/action.yml b/.github/actions/smoke-windows-artifact/action.yml index 90ce769..8cb9afe 100644 --- a/.github/actions/smoke-windows-artifact/action.yml +++ b/.github/actions/smoke-windows-artifact/action.yml @@ -74,6 +74,8 @@ runs: throw "Windows artifact smoke test failed." } + python "$env:GITHUB_WORKSPACE\scripts\run-subconverter-smoke.py" --base-url "http://127.0.0.1:$port" + if (-not (Test-Path $pref)) { throw "Windows launcher did not create base\pref.toml on first start." } diff --git a/scripts/run-subconverter-smoke.py b/scripts/run-subconverter-smoke.py new file mode 100644 index 0000000..0a1937f --- /dev/null +++ b/scripts/run-subconverter-smoke.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Run HTTP smoke checks against a running SubConverter-Extended instance. + +The script does not build the project. Point --base-url at a local or remote +test server and it will verify health, normal conversion, and explain output. +Snapshots are optional; pass --snapshot-dir and --update-snapshots to create or +refresh them. +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +SAMPLE_SS_LINK = "ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@example.com:8388#Smoke" +DISABLE_RULEGEN_CONFIG = "data:,enable_rule_generator=false" + + +def build_url(base_url: str, path: str, params: dict[str, str] | None = None) -> str: + base = base_url.rstrip("/") + query = urllib.parse.urlencode(params or {}) + return f"{base}{path}" + (f"?{query}" if query else "") + + +def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int) -> str: + url = build_url(base_url, path, params) + try: + with urllib.request.urlopen(url, timeout=timeout) as response: + status = response.status + body = response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise AssertionError(f"{url} returned HTTP {exc.code}\n{body}") from exc + except urllib.error.URLError as exc: + raise AssertionError(f"{url} failed: {exc}") from exc + + if status < 200 or status >= 300: + raise AssertionError(f"{url} returned HTTP {status}\n{body}") + return body + + +def assert_snapshot(name: str, content: str, snapshot_dir: Path | None, update: bool) -> None: + if snapshot_dir is None: + return + + snapshot_dir.mkdir(parents=True, exist_ok=True) + path = snapshot_dir / name + normalized = content.replace("\r\n", "\n") + if update or not path.exists(): + path.write_text(normalized, encoding="utf-8") + return + + expected = path.read_text(encoding="utf-8").replace("\r\n", "\n") + if expected != normalized: + diff = "\n".join( + difflib.unified_diff( + expected.splitlines(), + normalized.splitlines(), + fromfile=str(path), + tofile=f"current:{name}", + lineterm="", + ) + ) + raise AssertionError(f"Snapshot mismatch for {name}\n{diff}") + + +def run_checks(base_url: str, timeout: int, snapshot_dir: Path | None, update: bool) -> None: + health = fetch(base_url, "/healthz", None, timeout) + if health.strip() != "ok": + raise AssertionError(f"/healthz returned unexpected body: {health!r}") + + common_params = { + "target": "clash", + "url": SAMPLE_SS_LINK, + "config": DISABLE_RULEGEN_CONFIG, + } + + direct_config = fetch(base_url, "/sub", common_params, timeout) + if "Smoke" not in direct_config or "proxies:" not in direct_config: + raise AssertionError("direct Clash conversion did not include expected node output") + assert_snapshot("direct-clash.yaml", direct_config, snapshot_dir, update) + + direct_explain = fetch( + base_url, + "/sub", + {**common_params, "explain": "true"}, + timeout, + ) + direct_report = json.loads(direct_explain) + if direct_report.get("target") != "clash": + raise AssertionError(f"unexpected explain target: {direct_report.get('target')!r}") + if direct_report.get("nodes", {}).get("total", 0) < 1: + raise AssertionError("direct explain report did not count the parsed node") + assert_snapshot("direct-explain.json", direct_explain, snapshot_dir, update) + + provider_explain = fetch( + base_url, + "/sub", + { + "target": "clash", + "url": "https://example.com/sub", + "config": DISABLE_RULEGEN_CONFIG, + "explain": "true", + }, + timeout, + ) + provider_report = json.loads(provider_explain) + if not provider_report.get("mode", {}).get("proxy_provider"): + raise AssertionError("provider explain report did not enter proxy-provider mode") + if provider_report.get("output", {}).get("provider_count") != 1: + raise AssertionError("provider explain report did not count one provider") + assert_snapshot("provider-explain.json", provider_explain, snapshot_dir, update) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", default="http://127.0.0.1:25500") + parser.add_argument("--timeout", type=int, default=20) + parser.add_argument("--snapshot-dir", type=Path) + parser.add_argument("--update-snapshots", action="store_true") + args = parser.parse_args() + + try: + run_checks(args.base_url, args.timeout, args.snapshot_dir, args.update_snapshots) + except Exception as exc: + print(f"smoke checks failed: {exc}", file=sys.stderr) + return 1 + + print("smoke checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/handler/interfaces.cpp b/src/handler/interfaces.cpp index 9a49aed..19bb342 100644 --- a/src/handler/interfaces.cpp +++ b/src/handler/interfaces.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -12,6 +13,7 @@ #include #include +#include #include #include "config/binding.h" @@ -640,6 +642,179 @@ static std::map> static std::mutex g_sub_response_cache_mutex; static std::map g_sub_response_cache; +struct SubExplainProvider { + std::string name; + std::string tag; + std::string source_hash; + std::string path; + std::string filter; + std::string exclude_filter; + int group_id = 0; + uint32_t interval = 0; +}; + +struct SubExplainReport { + bool enabled = false; + std::string requested_target; + std::string target; + bool simple_subscription = false; + bool upload_requested = false; + bool upload_suppressed = false; + bool external_config_provided = false; + bool external_config_loaded = false; + bool fallback_config_used = false; + bool rule_generator_enabled = false; + bool expand_rulesets = false; + bool proxy_provider_mode = false; + bool nodelist = false; + bool managed_config = false; + std::string base_fetch_context = "trusted_config"; + std::string ruleset_fetch_context = "trusted_config"; + size_t raw_url_count = 0; + size_t insert_url_count = 0; + size_t subscription_url_count = 0; + size_t node_link_count = 0; + size_t unknown_node_link_count = 0; + size_t provider_count = 0; + size_t insert_node_count = 0; + size_t direct_node_count = 0; + size_t total_node_count = 0; + size_t ruleset_count = 0; + size_t custom_group_count = 0; + size_t output_bytes = 0; + std::vector providers; +}; + +static std::string fetchContextName(FetchContext context) { + switch (context) { + case FetchContext::PublicRequest: + return "public_request"; + case FetchContext::TrustedConfig: + default: + return "trusted_config"; + } +} + +static std::string shortHash(const std::string &value) { + if (value.empty()) + return ""; + return getMD5(value).substr(0, 10); +} + +static void writeJsonString( + rapidjson::Writer &writer, const char *key, + const std::string &value) { + writer.Key(key); + writer.String(value.c_str()); +} + +static std::string serializeSubExplainReport(const SubExplainReport &report, + const Response &response) { + rapidjson::StringBuffer buffer; + rapidjson::Writer writer(buffer); + + writer.StartObject(); + writer.Key("ok"); + writer.Bool(response.status_code >= 200 && response.status_code < 300); + writer.Key("status_code"); + writer.Int(response.status_code); + writeJsonString(writer, "requested_target", report.requested_target); + writeJsonString(writer, "target", report.target); + + writer.Key("mode"); + writer.StartObject(); + writer.Key("simple_subscription"); + writer.Bool(report.simple_subscription); + writer.Key("proxy_provider"); + writer.Bool(report.proxy_provider_mode); + writer.Key("nodelist"); + writer.Bool(report.nodelist); + writer.Key("expand_rulesets"); + writer.Bool(report.expand_rulesets); + writer.Key("rule_generator"); + writer.Bool(report.rule_generator_enabled); + writer.Key("managed_config"); + writer.Bool(report.managed_config); + writer.Key("upload_requested"); + writer.Bool(report.upload_requested); + writer.Key("upload_suppressed"); + writer.Bool(report.upload_suppressed); + writer.EndObject(); + + writer.Key("inputs"); + writer.StartObject(); + writer.Key("raw_url_count"); + writer.Uint64(report.raw_url_count); + writer.Key("insert_url_count"); + writer.Uint64(report.insert_url_count); + writer.Key("subscription_url_count"); + writer.Uint64(report.subscription_url_count); + writer.Key("node_link_count"); + writer.Uint64(report.node_link_count); + writer.Key("unknown_node_link_count"); + writer.Uint64(report.unknown_node_link_count); + writer.EndObject(); + + writer.Key("external_config"); + writer.StartObject(); + writer.Key("provided"); + writer.Bool(report.external_config_provided); + writer.Key("loaded"); + writer.Bool(report.external_config_loaded); + writer.Key("fallback_used"); + writer.Bool(report.fallback_config_used); + writer.EndObject(); + + writer.Key("resources"); + writer.StartObject(); + writeJsonString(writer, "base_fetch_context", report.base_fetch_context); + writeJsonString(writer, "ruleset_fetch_context", report.ruleset_fetch_context); + writer.Key("ruleset_count"); + writer.Uint64(report.ruleset_count); + writer.Key("custom_group_count"); + writer.Uint64(report.custom_group_count); + writer.EndObject(); + + writer.Key("nodes"); + writer.StartObject(); + writer.Key("insert"); + writer.Uint64(report.insert_node_count); + writer.Key("direct"); + writer.Uint64(report.direct_node_count); + writer.Key("total"); + writer.Uint64(report.total_node_count); + writer.EndObject(); + + writer.Key("providers"); + writer.StartArray(); + for (const SubExplainProvider &provider : report.providers) { + writer.StartObject(); + writeJsonString(writer, "name", provider.name); + writeJsonString(writer, "tag", provider.tag); + writeJsonString(writer, "source_hash", provider.source_hash); + writeJsonString(writer, "path", provider.path); + writeJsonString(writer, "filter", provider.filter); + writeJsonString(writer, "exclude_filter", provider.exclude_filter); + writer.Key("group_id"); + writer.Int(provider.group_id); + writer.Key("interval"); + writer.Uint(provider.interval); + writer.EndObject(); + } + writer.EndArray(); + + writer.Key("output"); + writer.StartObject(); + writer.Key("bytes"); + writer.Uint64(report.output_bytes); + writer.Key("provider_count"); + writer.Uint64(report.provider_count); + writer.EndObject(); + + writer.EndObject(); + return buffer.GetString(); +} + static bool isTruthyRequestValue(const std::string &value) { std::string normalized = toLower(trimWhitespace(value, true, true)); return normalized == "1" || normalized == "true" || @@ -873,11 +1048,16 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { std::string argTarget = getUrlArg(argument, "target"), argSurgeVer = getUrlArg(argument, "ver"); + bool explainMode = isTruthyRequestValue(getUrlArg(argument, "explain")); + SubExplainReport explain; + explain.enabled = explainMode; + explain.requested_target = argTarget; tribool argClashNewField = getUrlArg(argument, "new_name"); int intSurgeVer = !argSurgeVer.empty() ? to_int(argSurgeVer, 3) : 3; if (argTarget == "auto") matchUserAgent(request.headers["User-Agent"], argTarget, argClashNewField, intSurgeVer); + explain.target = argTarget; /// don't try to load groups or rulesets when generating simple subscriptions bool lSimpleSubscription = false; @@ -956,6 +1136,11 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { argGenClassicalRuleProvider = getUrlArg(argument, "classic"), argTLS13 = getUrlArg(argument, "tls13"), argProviderProxyDirect = getUrlArg(argument, "provider_proxy_direct"); + explain.upload_requested = argUpload.get(false); + if (explainMode && argUpload) { + argUpload = false; + explain.upload_suppressed = true; + } std::string base_content, output_content; ProxyGroupConfigs lCustomProxyGroups = global.customProxyGroups; @@ -972,6 +1157,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { bool authorized = false, strict = !argUpdateStrict.empty() ? argUpdateStrict == "true" : global.updateStrict; + explain.simple_subscription = lSimpleSubscription; if (std::find(gRegexBlacklist.cbegin(), gRegexBlacklist.cend(), argIncludeRemark) != gRegexBlacklist.cend() || @@ -1070,17 +1256,21 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { ext.clash_new_field_name = true; if (argExpandRulesets) ext.clash_script = false; + explain.expand_rulesets = argExpandRulesets.get(false); ext.nodelist = argGenNodeList; // 强制 list=false,直接覆盖用户提供的任何值 // 确保始终使用 proxy-provider 模式,而不是读取订阅并形成节点列表 ext.nodelist = false; + explain.nodelist = ext.nodelist; ext.surge_ssr_path = global.surgeSSRPath; ext.quanx_dev_id = !argDeviceID.empty() ? argDeviceID : global.quanXDevID; ext.enable_rule_generator = global.enableRuleGen; ext.overwrite_original_rules = global.overwriteOriginalRules; if (!argExpandRulesets) ext.managed_config_prefix = global.managedConfigPrefix; + explain.rule_generator_enabled = ext.enable_rule_generator; + explain.managed_config = !ext.managed_config_prefix.empty(); /// load external configuration std::string userProvidedConfig = getUrlArg(argument, "config"); @@ -1092,6 +1282,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { FetchContext baseFetchContext = FetchContext::TrustedConfig; bool configLoadSuccess = false; string_map tpl_args_base = tpl_args.local_vars; + explain.external_config_provided = userProvidedExternalConfig; if (argExternalConfig.empty()) argExternalConfig = global.defaultExtConfig; @@ -1107,6 +1298,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { if (load_result == 0 && hasEffectiveExternalConfig(extconf, tpl_args, tpl_args_base)) { configLoadSuccess = true; + explain.external_config_loaded = true; if (!ext.nodelist) { if (checkExternalBase(extconf.sssub_rule_base, lSSSubBase, externalConfigContext)) @@ -1191,6 +1383,8 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { writeLog(0, "已成功加载配置:" + fallbackUrl, LOG_LEVEL_INFO); configLoadSuccess = true; + explain.external_config_loaded = true; + explain.fallback_config_used = true; if (!ext.nodelist) { checkExternalBase(extconf.sssub_rule_base, lSSSubBase, FetchContext::TrustedConfig); @@ -1279,6 +1473,11 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { lRulesetContent = global.rulesetsContent; } } + explain.rule_generator_enabled = ext.enable_rule_generator; + explain.base_fetch_context = fetchContextName(baseFetchContext); + explain.ruleset_fetch_context = fetchContextName(rulesetFetchContext); + explain.ruleset_count = lRulesetContent.size(); + explain.custom_group_count = lCustomProxyGroups.size(); if (!argEmoji.is_undef()) { argAddEmoji.set(argEmoji); @@ -1334,6 +1533,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { if (!global.insertUrls.empty() && argEnableInsert) { groupID = -1; urls = split(global.insertUrls, "|"); + explain.insert_url_count = urls.size(); importItems(urls, true); for (std::string &x : urls) { x = regTrim(x); @@ -1359,6 +1559,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { } } urls = split(argUrl, "|"); + explain.raw_url_count = urls.size(); parse_set.fetch_context = FetchContext::PublicRequest; groupID = 0; @@ -1390,6 +1591,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { writeLog(0, "检测到节点链接:'" + link + "',将直接解析。", LOG_LEVEL_INFO); node_urls.push_back(node_link); + explain.node_link_count++; } else if (isLink(link) || mihomo::isHttpSchemeLink(link)) { // HTTP/HTTPS 订阅链接 writeLog( @@ -1397,6 +1599,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { LOG_LEVEL_INFO); subscription_urls.push_back( {link, tagged.tag, tagged.provider, tagged.link_decoded}); + explain.subscription_url_count++; } else { std::string node_link = link; if (tagged.has_tag) @@ -1404,6 +1607,8 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { writeLog(0, "未知 URL 类型:'" + link + "',按节点链接处理。", LOG_LEVEL_WARNING); node_urls.push_back(node_link); + explain.node_link_count++; + explain.unknown_node_link_count++; } } @@ -1475,6 +1680,16 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { } ext.providers.push_back(provider); + SubExplainProvider explain_provider; + explain_provider.name = provider.name; + explain_provider.tag = provider.tag; + explain_provider.source_hash = shortHash(provider.url); + explain_provider.path = provider.path; + explain_provider.filter = provider.filter; + explain_provider.exclude_filter = provider.exclude_filter; + explain_provider.group_id = provider.groupId; + explain_provider.interval = provider.interval; + explain.providers.push_back(std::move(explain_provider)); groupID++; } } else { @@ -1522,6 +1737,10 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { } // exit if found nothing // 对于 proxy-provider 模式,允许 nodes 为空(节点从 provider 获取) + explain.provider_count = ext.providers.size(); + explain.proxy_provider_mode = ext.use_proxy_provider && !ext.providers.empty(); + explain.insert_node_count = insert_nodes.size(); + explain.direct_node_count = nodes.size(); if (nodes.empty() && insert_nodes.empty() && ext.providers.empty()) { *status_code = 400; return "Invalid request: no valid proxy nodes or proxy providers were " @@ -1609,6 +1828,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { // do pre-process now preprocessNodes(nodes, ext); + explain.total_node_count = nodes.size(); /* //insert node info to template @@ -1879,6 +2099,11 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) { "请将该请求反馈给服务维护者。"; } writeLog(0, "生成完成。", LOG_LEVEL_INFO); + if (explainMode) { + explain.output_bytes = output_content.size(); + response.content_type = "application/json; charset=utf-8"; + return serializeSubExplainReport(explain, response); + } if (!argFilename.empty()) response.headers.emplace("Content-Disposition", "attachment; filename=\"" + argFilename + diff --git a/tests/snapshots/README.md b/tests/snapshots/README.md new file mode 100644 index 0000000..96a413d --- /dev/null +++ b/tests/snapshots/README.md @@ -0,0 +1,21 @@ +# SubConverter Smoke Snapshots + +This directory can hold optional golden outputs for +`scripts/run-subconverter-smoke.py`. + +Create or refresh snapshots against a running instance: + +```bash +python3 scripts/run-subconverter-smoke.py \ + --base-url http://127.0.0.1:25500 \ + --snapshot-dir tests/snapshots \ + --update-snapshots +``` + +Run comparison without updating: + +```bash +python3 scripts/run-subconverter-smoke.py \ + --base-url http://127.0.0.1:25500 \ + --snapshot-dir tests/snapshots +```