This commit is contained in:
truewhile
2026-09-30 19:22:24 +08:00
parent fa41936095
commit c1ff55ffbf
28 changed files with 3793 additions and 201 deletions
@@ -0,0 +1,317 @@
package rule
import (
"encoding/json"
"os"
"strings"
"testing"
)
// 本文件用「聚合类登录书源」的典型结构验证服务端 JS 运行时:
//
// jsLib(公共函数库)+ loginUrl(登录逻辑)+ loginUi(表单)
//
// 与真实书源(如光遇聚合,jsLib 约 15 万字符)同构,但精简为可维护的固件。
// 固件放在 testdata/,因此这些是常驻回归测试而非一次性 spike。
func loadTestdata(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatalf("读取固件 %s 失败: %v", name, err)
}
return string(b)
}
// sampleSourceProps 构造注入了 jsLib/loginUrl/loginUi 的书源属性。
func sampleSourceProps(t *testing.T) map[string]any {
t.Helper()
return map[string]any{
"bookSourceUrl": "https://v1.example-aggregate.com",
"bookSourceName": "样例聚合源",
"jsLib": loadTestdata(t, "sample_jsLib.js"),
"loginUrl": loadTestdata(t, "sample_loginUrl.js"),
"loginUi": loadTestdata(t, "sample_loginUi.json"),
}
}
// newSampleRunner 用聚合源固件构建运行时,网络由测试桩接管。
func newSampleRunner(t *testing.T, state SourceState) *JSRunner {
t.Helper()
props := sampleSourceProps(t)
r := NewJSRunner(JSConfig{
SourceProps: props,
JSLib: props["jsLib"].(string),
State: state,
BaseURL: "https://v1.example-aggregate.com",
Fetch: func(req *Request) (string, string, int, error) {
if strings.Contains(req.URL, "/login_api") {
return `{"code":0,"key":"TOKEN_abcdefghijklmn"}`, req.URL, 200, nil
}
if strings.Contains(req.URL, "/user_api") {
return `{"id":1,"email":"user@example.com","nickname":"tester"}`, req.URL, 200, nil
}
return `{}`, req.URL, 200, nil
},
})
if err := r.JSLibErr(); err != nil {
t.Fatalf("jsLib 执行失败: %v", err)
}
return r
}
// TestJSLibProvidesHelpers jsLib 里的函数与 lexical 绑定(let hosts)应跨执行可见。
func TestJSLibProvidesHelpers(t *testing.T) {
r := newSampleRunner(t, NewMemoryState())
v, err := r.EvalAction(`typeof getVariable + '|' + typeof request + '|' + typeof hosts`, nil)
if err != nil {
t.Fatalf("EvalAction 失败: %v", err)
}
if got := anyToString(v); got != "function|function|object" {
t.Fatalf("jsLib 符号不可见: %q", got)
}
// BaseUrl() 依赖 lexical hosts 与 source.getVariable
v, err = r.EvalAction(`BaseUrl()`, nil)
if err != nil {
t.Fatalf("BaseUrl 失败: %v", err)
}
if got := anyToString(v); got != "https://v1.example-aggregate.com" {
t.Fatalf("BaseUrl() = %q", got)
}
// login 由 loginUrl 提供,未拼 loginUrl 时不应存在
v, _ = r.EvalAction(`typeof login`, nil)
if anyToString(v) != "undefined" {
t.Fatalf("login 应仅由 loginUrl 定义,当前 %q", anyToString(v))
}
}
// TestLoginFlowWritesCookies login() 应请求登录接口、写入 Cookie,且 getToken 能读回。
func TestLoginFlowWritesCookies(t *testing.T) {
state := NewMemoryState()
r := newSampleRunner(t, state)
loginJS := loadTestdata(t, "sample_loginUrl.js")
_, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{
"result": map[string]string{"邮箱": "user@example.com", "密码": "pw123456"},
})
if err != nil {
t.Fatalf("login(true) 执行失败: %v", err)
}
v, err := r.EvalAction(`getToken()`, nil)
if err != nil {
t.Fatalf("getToken 失败: %v", err)
}
if got := anyToString(v); got != "TOKEN_abcdefghijklmn" {
t.Fatalf("getToken() = %q,期望登录后能读到 token", got)
}
// setAllCookies 遍历 hosts;两条线路同属 example-aggregate.com,
// 按 eTLD+1 归并成一条 Cookie,两个线路地址都应能读到。
if len(state.Snapshot()) != 1 {
t.Fatalf("同站线路应归并为一条 Cookie,实际 %v", state.Snapshot())
}
for _, host := range []string{"https://v1.example-aggregate.com", "https://v2.example-aggregate.com"} {
if !strings.Contains(state.GetCookie(host), "qttoken=TOKEN_abcdefghijklmn") {
t.Fatalf("%s 未读到 token: %q", host, state.GetCookie(host))
}
}
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "登录成功") {
t.Fatalf("未收到登录成功提示: %v", state.Toasts())
}
}
// TestLoginMissingCredentials 缺少账号密码时应给出提示且不产生 Cookie。
func TestLoginMissingCredentials(t *testing.T) {
state := NewMemoryState()
r := newSampleRunner(t, state)
loginJS := loadTestdata(t, "sample_loginUrl.js")
if _, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{"result": map[string]string{}}); err != nil {
t.Fatalf("执行失败: %v", err)
}
if len(state.Snapshot()) != 0 {
t.Fatalf("未填表单不应写入 Cookie: %v", state.Snapshot())
}
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "请先输入账号密码") {
t.Fatalf("缺少提示语: %v", state.Toasts())
}
}
// TestStartBrowserAwaitRecordsURL startBrowserAwait 应记录待打开地址并明确报错,
// 避免书源逻辑把空 body 当成校验成功。
func TestStartBrowserAwaitRecordsURL(t *testing.T) {
state := NewMemoryState()
r := newSampleRunner(t, state)
loginJS := loadTestdata(t, "sample_loginUrl.js")
// 先造出登录态,让 user() 走到 startBrowserAwait
state.SetCookie("https://v1.example-aggregate.com", "qttoken=TOKEN_abcdefghijklmn")
_, err := r.EvalAction(loginJS+"\nuser()", nil)
if err == nil || !strings.Contains(err.Error(), "无浏览器") {
t.Fatalf("应明确报不支持,实际: %v", err)
}
browsers := state.Browsers()
if len(browsers) != 1 || !strings.HasSuffix(browsers[0].URL, "/user") {
t.Fatalf("未记录待打开地址: %+v", browsers)
}
}
// TestSourceLoginInfoMapInit 未保存登录信息时,getLoginInfoMap 用 loginUi 的
// 非按钮字段初始化(对应 legado)。
func TestSourceLoginInfoMapInit(t *testing.T) {
r := newSampleRunner(t, NewMemoryState())
v, err := r.EvalAction(`JSON.stringify(source.getLoginInfoMap())`, nil)
if err != nil {
t.Fatalf("getLoginInfoMap 失败: %v", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(anyToString(v)), &m); err != nil {
t.Fatalf("返回不是 JSON 对象: %q", anyToString(v))
}
if _, ok := m["邮箱"]; !ok {
t.Fatalf("loginUi 的「邮箱」未出现在登录信息: %v", m)
}
if _, ok := m["密码"]; !ok {
t.Fatalf("loginUi 的「密码」未出现在登录信息: %v", m)
}
for k := range m {
if strings.Contains(k, "登录") || strings.Contains(k, "注册") || strings.Contains(k, "后台") {
t.Fatalf("button 字段不应出现在登录信息: %v", m)
}
}
}
// TestSourceVariablesRoundTrip 变量经 source.setVariable 写入后可由 getVariable 读出。
func TestSourceVariablesRoundTrip(t *testing.T) {
state := NewMemoryState()
r := newSampleRunner(t, state)
if _, err := r.EvalAction(`setVariable('线路','https://v2.example-aggregate.com',false)`, nil); err != nil {
t.Fatalf("setVariable 失败: %v", err)
}
v, err := r.EvalAction(`getVariable('线路')`, nil)
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != "https://v2.example-aggregate.com" {
t.Fatalf("getVariable('线路') = %q", got)
}
// 变量变更应反映到状态存储(持久化的前提)
if !strings.Contains(state.GetVariable(), "v2.example-aggregate.com") {
t.Fatalf("变量未写回状态存储: %q", state.GetVariable())
}
// BaseUrl 也应跟随变量变化
v, _ = r.EvalAction(`BaseUrl()`, nil)
if anyToString(v) != "https://v2.example-aggregate.com" {
t.Fatalf("BaseUrl 未跟随变量: %q", anyToString(v))
}
}
// TestCookieObjectIsolation Cookie 按站点隔离,同站子域共享,removeCookie 可清除。
func TestCookieObjectIsolation(t *testing.T) {
state := NewMemoryState()
r := NewJSRunner(JSConfig{State: state})
if _, err := r.EvalAction(`cookie.setCookie('https://a.example.com/x','t=1; u=2')`, nil); err != nil {
t.Fatal(err)
}
v, err := r.EvalAction(`cookie.getCookie('https://a.example.com/y','t')`, nil)
if err != nil {
t.Fatal(err)
}
if anyToString(v) != "1" {
t.Fatalf("getCookie key = %q", anyToString(v))
}
// 同站子域共享 Cookie(对应 legado getSubDomain 取 eTLD+1)
v, _ = r.EvalAction(`cookie.getCookie('https://www.example.com/')`, nil)
if anyToString(v) == "" {
t.Fatal("同站子域应共享 Cookie")
}
// 不同站点互不污染
v, _ = r.EvalAction(`cookie.getCookie('https://other-site.net/')`, nil)
if anyToString(v) != "" {
t.Fatalf("跨站点读到了 Cookie: %q", anyToString(v))
}
// removeCookie 后读不到
if _, err := r.EvalAction(`cookie.removeCookie('https://a.example.com')`, nil); err != nil {
t.Fatal(err)
}
v, _ = r.EvalAction(`cookie.getCookie('https://a.example.com/')`, nil)
if anyToString(v) != "" {
t.Fatalf("removeCookie 后仍读到: %q", anyToString(v))
}
}
// TestSourceStatePersistsAcrossRunners 状态存储是跨运行时共享的:
// 模拟服务端重启(新建 runner)后登录态仍在。
func TestSourceStatePersistsAcrossRunners(t *testing.T) {
state := NewMemoryState()
loginJS := loadTestdata(t, "sample_loginUrl.js")
r1 := newSampleRunner(t, state)
if _, err := r1.EvalAction(loginJS+"\nlogin(true)", map[string]any{
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
}); err != nil {
t.Fatal(err)
}
// 新运行时复用同一 state(对应从 DB 重新载入)
r2 := newSampleRunner(t, state)
v, err := r2.EvalAction(`getToken()`, nil)
if err != nil {
t.Fatal(err)
}
if anyToString(v) != "TOKEN_abcdefghijklmn" {
t.Fatalf("新运行时未读到登录态: %q", anyToString(v))
}
// 已登录时再次 login 应提示已登录(走 getToken 短路分支)
if _, err := r2.EvalAction(loginJS+"\nlogin(true)", map[string]any{
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
}); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "已登录") {
t.Fatalf("已登录时应有提示: %v", state.Toasts())
}
}
// TestLoginCheckJsReplacesBody loginCheckJs 返回新响应体时应替换原 body
// (书源借此检测会话失效并重取页面)。
func TestLoginCheckJsReplacesBody(t *testing.T) {
r := NewJSRunner(JSConfig{State: NewMemoryState()})
// 返回响应对象(对应 legado 要求 loginCheckJs 返回 StrResponse)
js := `if (result.body().indexOf('未登录') >= 0) { java.toast('会话失效,重新登录'); }
var ok = JSON.stringify({code: 0, body: result.body()});
result.body()`
body, changed, err := r.EvalLoginCheck(js, "未登录", 200, "https://x.com/a")
if err != nil {
t.Fatalf("EvalLoginCheck 失败: %v", err)
}
if !changed || body != "未登录" {
t.Fatalf("body=%q changed=%v", body, changed)
}
}
// TestLoginCheckJsReturnsString loginCheckJs 直接返回字符串时也应生效。
func TestLoginCheckJsReturnsString(t *testing.T) {
r := NewJSRunner(JSConfig{State: NewMemoryState()})
body, changed, err := r.EvalLoginCheck(`'已修复内容'`, "原始", 200, "https://x.com/a")
if err != nil {
t.Fatal(err)
}
if !changed || body != "已修复内容" {
t.Fatalf("body=%q changed=%v", body, changed)
}
}
// TestLoginCheckJsResponseAccessors 验证 result 暴露 code()/url()/header()。
func TestLoginCheckJsResponseAccessors(t *testing.T) {
r := NewJSRunner(JSConfig{State: NewMemoryState()})
body, changed, err := r.EvalLoginCheck(
`result.code() + '|' + result.url()`, "x", 403, "https://x.com/y")
if err != nil {
t.Fatal(err)
}
if !changed || body != "403|https://x.com/y" {
t.Fatalf("body=%q", body)
}
}