feat(sub): add explain mode smoke coverage
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
141
scripts/run-subconverter-smoke.py
Normal file
141
scripts/run-subconverter-smoke.py
Normal 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())
|
||||||
@@ -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
21
tests/snapshots/README.md
Normal 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user