feat(sub): add explain mode smoke coverage

This commit is contained in:
Aethersailor
2026-05-25 17:21:58 +08:00
parent 88ad3dcfee
commit 85290de257
6 changed files with 391 additions and 0 deletions

View File

@@ -40,6 +40,7 @@ runs:
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if curl -fsS "$URL" >/tmp/subconverter-smoke.out; then if curl -fsS "$URL" >/tmp/subconverter-smoke.out; then
head -c 200 /tmp/subconverter-smoke.out head -c 200 /tmp/subconverter-smoke.out
python3 scripts/run-subconverter-smoke.py --base-url "http://127.0.0.1:${HOST_PORT}"
exit 0 exit 0
fi fi

View File

@@ -42,6 +42,7 @@ runs:
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if curl -fsS "$URL" >"$WORKDIR/response.out"; then if curl -fsS "$URL" >"$WORKDIR/response.out"; then
head -c 200 "$WORKDIR/response.out" head -c 200 "$WORKDIR/response.out"
python3 "$GITHUB_WORKSPACE/scripts/run-subconverter-smoke.py" --base-url "http://127.0.0.1:${PORT}"
exit 0 exit 0
fi fi

View File

@@ -74,6 +74,8 @@ runs:
throw "Windows artifact smoke test failed." 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)) { if (-not (Test-Path $pref)) {
throw "Windows launcher did not create base\pref.toml on first start." throw "Windows launcher did not create base\pref.toml on first start."
} }

View File

@@ -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())

View File

@@ -1,6 +1,7 @@
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
#include <condition_variable> #include <condition_variable>
#include <cstdint>
#include <ctime> #include <ctime>
#include <exception> #include <exception>
#include <iostream> #include <iostream>
@@ -12,6 +13,7 @@
#include <unordered_set> #include <unordered_set>
#include <inja.hpp> #include <inja.hpp>
#include <rapidjson/stringbuffer.h>
#include <yaml-cpp/yaml.h> #include <yaml-cpp/yaml.h>
#include "config/binding.h" #include "config/binding.h"
@@ -640,6 +642,179 @@ static std::map<std::string, std::shared_ptr<InflightSubRequest>>
static std::mutex g_sub_response_cache_mutex; static std::mutex g_sub_response_cache_mutex;
static std::map<std::string, CachedSubResponse> g_sub_response_cache; static std::map<std::string, CachedSubResponse> 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<SubExplainProvider> 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<rapidjson::StringBuffer> &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<rapidjson::StringBuffer> 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) { static bool isTruthyRequestValue(const std::string &value) {
std::string normalized = toLower(trimWhitespace(value, true, true)); std::string normalized = toLower(trimWhitespace(value, true, true));
return normalized == "1" || normalized == "true" || return normalized == "1" || normalized == "true" ||
@@ -873,11 +1048,16 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
std::string argTarget = getUrlArg(argument, "target"), std::string argTarget = getUrlArg(argument, "target"),
argSurgeVer = getUrlArg(argument, "ver"); 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"); tribool argClashNewField = getUrlArg(argument, "new_name");
int intSurgeVer = !argSurgeVer.empty() ? to_int(argSurgeVer, 3) : 3; int intSurgeVer = !argSurgeVer.empty() ? to_int(argSurgeVer, 3) : 3;
if (argTarget == "auto") if (argTarget == "auto")
matchUserAgent(request.headers["User-Agent"], argTarget, argClashNewField, matchUserAgent(request.headers["User-Agent"], argTarget, argClashNewField,
intSurgeVer); intSurgeVer);
explain.target = argTarget;
/// don't try to load groups or rulesets when generating simple subscriptions /// don't try to load groups or rulesets when generating simple subscriptions
bool lSimpleSubscription = false; bool lSimpleSubscription = false;
@@ -956,6 +1136,11 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
argGenClassicalRuleProvider = getUrlArg(argument, "classic"), argGenClassicalRuleProvider = getUrlArg(argument, "classic"),
argTLS13 = getUrlArg(argument, "tls13"), argTLS13 = getUrlArg(argument, "tls13"),
argProviderProxyDirect = getUrlArg(argument, "provider_proxy_direct"); 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; std::string base_content, output_content;
ProxyGroupConfigs lCustomProxyGroups = global.customProxyGroups; ProxyGroupConfigs lCustomProxyGroups = global.customProxyGroups;
@@ -972,6 +1157,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
bool authorized = false, strict = !argUpdateStrict.empty() bool authorized = false, strict = !argUpdateStrict.empty()
? argUpdateStrict == "true" ? argUpdateStrict == "true"
: global.updateStrict; : global.updateStrict;
explain.simple_subscription = lSimpleSubscription;
if (std::find(gRegexBlacklist.cbegin(), gRegexBlacklist.cend(), if (std::find(gRegexBlacklist.cbegin(), gRegexBlacklist.cend(),
argIncludeRemark) != gRegexBlacklist.cend() || argIncludeRemark) != gRegexBlacklist.cend() ||
@@ -1070,17 +1256,21 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
ext.clash_new_field_name = true; ext.clash_new_field_name = true;
if (argExpandRulesets) if (argExpandRulesets)
ext.clash_script = false; ext.clash_script = false;
explain.expand_rulesets = argExpandRulesets.get(false);
ext.nodelist = argGenNodeList; ext.nodelist = argGenNodeList;
// 强制 list=false直接覆盖用户提供的任何值 // 强制 list=false直接覆盖用户提供的任何值
// 确保始终使用 proxy-provider 模式,而不是读取订阅并形成节点列表 // 确保始终使用 proxy-provider 模式,而不是读取订阅并形成节点列表
ext.nodelist = false; ext.nodelist = false;
explain.nodelist = ext.nodelist;
ext.surge_ssr_path = global.surgeSSRPath; ext.surge_ssr_path = global.surgeSSRPath;
ext.quanx_dev_id = !argDeviceID.empty() ? argDeviceID : global.quanXDevID; ext.quanx_dev_id = !argDeviceID.empty() ? argDeviceID : global.quanXDevID;
ext.enable_rule_generator = global.enableRuleGen; ext.enable_rule_generator = global.enableRuleGen;
ext.overwrite_original_rules = global.overwriteOriginalRules; ext.overwrite_original_rules = global.overwriteOriginalRules;
if (!argExpandRulesets) if (!argExpandRulesets)
ext.managed_config_prefix = global.managedConfigPrefix; 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 /// load external configuration
std::string userProvidedConfig = getUrlArg(argument, "config"); std::string userProvidedConfig = getUrlArg(argument, "config");
@@ -1092,6 +1282,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
FetchContext baseFetchContext = FetchContext::TrustedConfig; FetchContext baseFetchContext = FetchContext::TrustedConfig;
bool configLoadSuccess = false; bool configLoadSuccess = false;
string_map tpl_args_base = tpl_args.local_vars; string_map tpl_args_base = tpl_args.local_vars;
explain.external_config_provided = userProvidedExternalConfig;
if (argExternalConfig.empty()) if (argExternalConfig.empty())
argExternalConfig = global.defaultExtConfig; argExternalConfig = global.defaultExtConfig;
@@ -1107,6 +1298,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
if (load_result == 0 && if (load_result == 0 &&
hasEffectiveExternalConfig(extconf, tpl_args, tpl_args_base)) { hasEffectiveExternalConfig(extconf, tpl_args, tpl_args_base)) {
configLoadSuccess = true; configLoadSuccess = true;
explain.external_config_loaded = true;
if (!ext.nodelist) { if (!ext.nodelist) {
if (checkExternalBase(extconf.sssub_rule_base, lSSSubBase, if (checkExternalBase(extconf.sssub_rule_base, lSSSubBase,
externalConfigContext)) externalConfigContext))
@@ -1191,6 +1383,8 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
writeLog(0, "已成功加载配置:" + fallbackUrl, writeLog(0, "已成功加载配置:" + fallbackUrl,
LOG_LEVEL_INFO); LOG_LEVEL_INFO);
configLoadSuccess = true; configLoadSuccess = true;
explain.external_config_loaded = true;
explain.fallback_config_used = true;
if (!ext.nodelist) { if (!ext.nodelist) {
checkExternalBase(extconf.sssub_rule_base, lSSSubBase, checkExternalBase(extconf.sssub_rule_base, lSSSubBase,
FetchContext::TrustedConfig); FetchContext::TrustedConfig);
@@ -1279,6 +1473,11 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
lRulesetContent = global.rulesetsContent; 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()) { if (!argEmoji.is_undef()) {
argAddEmoji.set(argEmoji); argAddEmoji.set(argEmoji);
@@ -1334,6 +1533,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
if (!global.insertUrls.empty() && argEnableInsert) { if (!global.insertUrls.empty() && argEnableInsert) {
groupID = -1; groupID = -1;
urls = split(global.insertUrls, "|"); urls = split(global.insertUrls, "|");
explain.insert_url_count = urls.size();
importItems(urls, true); importItems(urls, true);
for (std::string &x : urls) { for (std::string &x : urls) {
x = regTrim(x); x = regTrim(x);
@@ -1359,6 +1559,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
} }
} }
urls = split(argUrl, "|"); urls = split(argUrl, "|");
explain.raw_url_count = urls.size();
parse_set.fetch_context = FetchContext::PublicRequest; parse_set.fetch_context = FetchContext::PublicRequest;
groupID = 0; groupID = 0;
@@ -1390,6 +1591,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
writeLog(0, "检测到节点链接:'" + link + "',将直接解析。", writeLog(0, "检测到节点链接:'" + link + "',将直接解析。",
LOG_LEVEL_INFO); LOG_LEVEL_INFO);
node_urls.push_back(node_link); node_urls.push_back(node_link);
explain.node_link_count++;
} else if (isLink(link) || mihomo::isHttpSchemeLink(link)) { } else if (isLink(link) || mihomo::isHttpSchemeLink(link)) {
// HTTP/HTTPS 订阅链接 // HTTP/HTTPS 订阅链接
writeLog( writeLog(
@@ -1397,6 +1599,7 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
LOG_LEVEL_INFO); LOG_LEVEL_INFO);
subscription_urls.push_back( subscription_urls.push_back(
{link, tagged.tag, tagged.provider, tagged.link_decoded}); {link, tagged.tag, tagged.provider, tagged.link_decoded});
explain.subscription_url_count++;
} else { } else {
std::string node_link = link; std::string node_link = link;
if (tagged.has_tag) if (tagged.has_tag)
@@ -1404,6 +1607,8 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
writeLog(0, "未知 URL 类型:'" + link + "',按节点链接处理。", writeLog(0, "未知 URL 类型:'" + link + "',按节点链接处理。",
LOG_LEVEL_WARNING); LOG_LEVEL_WARNING);
node_urls.push_back(node_link); 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); 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++; groupID++;
} }
} else { } else {
@@ -1522,6 +1737,10 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
} }
// exit if found nothing // exit if found nothing
// 对于 proxy-provider 模式,允许 nodes 为空(节点从 provider 获取) // 对于 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()) { if (nodes.empty() && insert_nodes.empty() && ext.providers.empty()) {
*status_code = 400; *status_code = 400;
return "Invalid request: no valid proxy nodes or proxy providers were " 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 // do pre-process now
preprocessNodes(nodes, ext); preprocessNodes(nodes, ext);
explain.total_node_count = nodes.size();
/* /*
//insert node info to template //insert node info to template
@@ -1879,6 +2099,11 @@ static std::string subconverter_impl(RESPONSE_CALLBACK_ARGS) {
"请将该请求反馈给服务维护者。"; "请将该请求反馈给服务维护者。";
} }
writeLog(0, "生成完成。", LOG_LEVEL_INFO); 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()) if (!argFilename.empty())
response.headers.emplace("Content-Disposition", response.headers.emplace("Content-Disposition",
"attachment; filename=\"" + argFilename + "attachment; filename=\"" + argFilename +

21
tests/snapshots/README.md Normal file
View File

@@ -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
```