mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
publish: 1.0.0 snapshot (bad2ec1)
channel: master version: 1.0.0 source-ref: master published-at-utc: 2026-03-13T15:19:28Z
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
package launchcode_test
|
||||
|
||||
// Feature: instance-launch-code, Property 2: persistence round-trip
|
||||
// Validates: Requirements 1.3, 5.1, 5.3
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// newTestDB 创建内存 SQLite 数据库并执行建表迁移
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file::memory:?cache=shared&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建索引失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// newFileTestDB 创建基于文件的 SQLite 数据库(用于需要独立隔离的测试)
|
||||
func newFileTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "launchcode_test_*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时数据库文件失败: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
dbPath := f.Name()
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建索引失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Close()
|
||||
os.Remove(dbPath)
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
// TestProperty2_PersistenceRoundTrip
|
||||
// Property 2: 持久化 Round-Trip
|
||||
// 对于任意 ProfileId 和 LaunchCode,Upsert 后:
|
||||
// - FindProfileId(code) 返回相同的 profileId
|
||||
// - FindCode(profileId) 返回相同的 code
|
||||
func TestProperty2_PersistenceRoundTrip(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("Upsert 后 FindProfileId 返回正确 profileId", prop.ForAll(
|
||||
func(profileId, code string) bool {
|
||||
db := newFileTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := dao.FindProfileId(code)
|
||||
return err == nil && got == profileId
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.Property("Upsert 后 FindCode 返回正确 code", prop.ForAll(
|
||||
func(profileId, code string) bool {
|
||||
db := newFileTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := dao.FindCode(profileId)
|
||||
return err == nil && got == code
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.Property("Upsert 幂等:相同 profileId 更新 code 后查询返回新 code", prop.ForAll(
|
||||
func(profileId, code1, code2 string) bool {
|
||||
if code1 == code2 {
|
||||
return true // 跳过相同 code 的情况
|
||||
}
|
||||
db := newFileTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code1); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := dao.Upsert(profileId, code2); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := dao.FindCode(profileId)
|
||||
return err == nil && got == code2
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty2_DeleteRemovesMapping
|
||||
// Property 2 补充:Delete 后查询应返回 not found
|
||||
func TestProperty2_DeleteRemovesMapping(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("Delete 后 FindCode 返回错误", prop.ForAll(
|
||||
func(profileId, code string) bool {
|
||||
db := newFileTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := dao.Delete(profileId); err != nil {
|
||||
return false
|
||||
}
|
||||
_, err := dao.FindCode(profileId)
|
||||
return err != nil
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty2_LoadAllRoundTrip
|
||||
// Property 2 补充:LoadAll 返回所有已写入的映射
|
||||
func TestProperty2_LoadAllRoundTrip(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
// 写入一批映射
|
||||
entries := map[string]string{}
|
||||
for i := 0; i < 10; i++ {
|
||||
profileId := fmt.Sprintf("profile-%02d", i)
|
||||
code := fmt.Sprintf("CODE%02d", i)
|
||||
entries[profileId] = code
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
t.Fatalf("Upsert 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
loaded, err := dao.LoadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAll 失败: %v", err)
|
||||
}
|
||||
|
||||
for profileId, code := range entries {
|
||||
got, ok := loaded[profileId]
|
||||
if !ok {
|
||||
t.Errorf("LoadAll 缺少 profileId=%s", profileId)
|
||||
continue
|
||||
}
|
||||
if got != code {
|
||||
t.Errorf("LoadAll profileId=%s: 期望 code=%s,实际=%s", profileId, code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
type mockStarterWithParams struct {
|
||||
profiles map[string]*browser.Profile
|
||||
lastProfile string
|
||||
lastParams launchcode.LaunchRequestParams
|
||||
}
|
||||
|
||||
func newMockStarterWithParams() *mockStarterWithParams {
|
||||
return &mockStarterWithParams{profiles: make(map[string]*browser.Profile)}
|
||||
}
|
||||
|
||||
func (m *mockStarterWithParams) addProfile(p *browser.Profile) {
|
||||
m.profiles[p.ProfileId] = p
|
||||
}
|
||||
|
||||
func (m *mockStarterWithParams) StartInstance(profileId string) (*browser.Profile, error) {
|
||||
m.lastProfile = profileId
|
||||
p, ok := m.profiles[profileId]
|
||||
if !ok {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *mockStarterWithParams) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) {
|
||||
m.lastProfile = profileId
|
||||
m.lastParams = params
|
||||
p, ok := m.profiles[profileId]
|
||||
if !ok {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func TestLaunchWithParams(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
starter.addProfile(&browser.Profile{
|
||||
ProfileId: "profile-automation",
|
||||
ProfileName: "automation",
|
||||
Pid: 321,
|
||||
DebugPort: 9555,
|
||||
})
|
||||
|
||||
code, err := svc.EnsureCode("profile-automation")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
body := map[string]interface{}{
|
||||
"code": code,
|
||||
"launchArgs": []string{"--window-size=1280,800", "--lang=en-US"},
|
||||
"startUrls": []string{"https://example.com"},
|
||||
"skipDefaultStartUrls": true,
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "profile-automation" {
|
||||
t.Fatalf("profileId 传递错误: %s", starter.lastProfile)
|
||||
}
|
||||
if len(starter.lastParams.LaunchArgs) != 2 {
|
||||
t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" {
|
||||
t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs)
|
||||
}
|
||||
if !starter.lastParams.SkipDefaultStartURLs {
|
||||
t.Fatal("skipDefaultStartUrls 传递错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithParamsBadRequest(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
t.Run("invalid-json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString("{bad json}"))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing-code", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"launchArgs":["--incognito"]}`))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLaunchLogsEndpoint(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
starter.addProfile(&browser.Profile{
|
||||
ProfileId: "profile-log-test",
|
||||
ProfileName: "log-test",
|
||||
Pid: 456,
|
||||
DebugPort: 9666,
|
||||
})
|
||||
|
||||
code, err := svc.EnsureCode("profile-log-test")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
payload := bytes.NewBufferString(`{"code":"` + code + `","launchArgs":["--incognito"]}`)
|
||||
|
||||
reqLaunch := httptest.NewRequest(http.MethodPost, "/api/launch", payload)
|
||||
reqLaunch.Header.Set("Content-Type", "application/json")
|
||||
wLaunch := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wLaunch, reqLaunch)
|
||||
if wLaunch.Code != http.StatusOK {
|
||||
t.Fatalf("调用 launch 失败: %d", wLaunch.Code)
|
||||
}
|
||||
|
||||
reqLogs := httptest.NewRequest(http.MethodGet, "/api/launch/logs?limit=10", nil)
|
||||
wLogs := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wLogs, reqLogs)
|
||||
if wLogs.Code != http.StatusOK {
|
||||
t.Fatalf("查询 logs 失败: %d", wLogs.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Items []launchcode.LaunchCallRecord `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(wLogs.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析 logs 响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK {
|
||||
t.Fatal("logs 响应 ok=false")
|
||||
}
|
||||
if len(resp.Items) == 0 {
|
||||
t.Fatal("logs 为空,期望至少一条记录")
|
||||
}
|
||||
if resp.Items[0].Path != "/api/launch" {
|
||||
t.Fatalf("最新记录 path 不正确: %s", resp.Items[0].Path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package launchcode_test
|
||||
|
||||
// Feature: instance-launch-code, Property 6: valid code response structure
|
||||
// Feature: instance-launch-code, Property 7: invalid code returns 404
|
||||
// Feature: instance-launch-code, Property 8: idempotent launch
|
||||
// Validates: Requirements 3.2, 3.3, 3.4, 3.5, 4.1, 4.2, 4.4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// --- 测试辅助类型 ---
|
||||
|
||||
// mockStarter 模拟 BrowserStarter,记录调用次数
|
||||
type mockStarter struct {
|
||||
profiles map[string]*browser.Profile
|
||||
callCounts map[string]int
|
||||
}
|
||||
|
||||
func newMockStarter() *mockStarter {
|
||||
return &mockStarter{
|
||||
profiles: make(map[string]*browser.Profile),
|
||||
callCounts: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockStarter) addProfile(p *browser.Profile) {
|
||||
m.profiles[p.ProfileId] = p
|
||||
}
|
||||
|
||||
func (m *mockStarter) StartInstance(profileId string) (*browser.Profile, error) {
|
||||
m.callCounts[profileId]++
|
||||
p, ok := m.profiles[profileId]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("profile not found: %s", profileId)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// buildTestHandler 构建一个可直接用于 httptest 的 handler(绕过 localhost 中间件)
|
||||
// 通过直接调用 server 内部 handler 的方式,使用 httptest.NewRecorder 测试路由逻辑
|
||||
func buildTestHandler(svc *launchcode.LaunchCodeService, starter launchcode.BrowserStarter) http.Handler {
|
||||
srv := launchcode.NewLaunchServer(svc, starter, nil, 0)
|
||||
return launchcode.NewTestHandler(srv)
|
||||
}
|
||||
|
||||
// newInMemoryService 创建一个使用内存 DAO 的 LaunchCodeService
|
||||
func newInMemoryService() *launchcode.LaunchCodeService {
|
||||
dao := launchcode.NewMemoryLaunchCodeDAO()
|
||||
return launchcode.NewLaunchCodeService(dao)
|
||||
}
|
||||
|
||||
// --- Property 6: 有效 Code 返回正确响应结构 ---
|
||||
|
||||
// genNonEmptyAlpha 生成长度 1-32 的字母字符串(不使用 SuchThat 过滤)
|
||||
func genNonEmptyAlpha() gopter.Gen {
|
||||
return gen.SliceOfN(8, gen.RuneRange('a', 'z')).Map(func(runes []rune) string {
|
||||
return string(runes)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProperty6_ValidCodeResponseStructure
|
||||
// 对于任意存在的 LaunchCode,GET /api/launch/{code} 应返回:
|
||||
// - HTTP 200
|
||||
// - Content-Type: application/json
|
||||
// - 响应体含 ok:true, profileId, profileName, pid, debugPort
|
||||
func TestProperty6_ValidCodeResponseStructure(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("有效 code 返回 200 及正确响应结构", prop.ForAll(
|
||||
func(profileId, profileName string, pid, debugPort int) bool {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: profileName,
|
||||
Pid: pid,
|
||||
DebugPort: debugPort,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
|
||||
code, err := svc.EnsureCode(profileId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Type"), "application/json") {
|
||||
return false
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ok, _ := resp["ok"].(bool)
|
||||
gotProfileId, _ := resp["profileId"].(string)
|
||||
gotProfileName, _ := resp["profileName"].(string)
|
||||
_, hasPid := resp["pid"]
|
||||
_, hasDebugPort := resp["debugPort"]
|
||||
|
||||
return ok &&
|
||||
gotProfileId == profileId &&
|
||||
gotProfileName == profileName &&
|
||||
hasPid && hasDebugPort
|
||||
},
|
||||
genNonEmptyAlpha(),
|
||||
genNonEmptyAlpha(),
|
||||
gen.IntRange(1000, 99999),
|
||||
gen.IntRange(9000, 9999),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// --- Property 7: 无效 Code 返回 404 ---
|
||||
|
||||
// genInvalidCode 生成一定不存在于空 service 中的 code(小写字母,不符合 A-Z0-9 格式)
|
||||
func genInvalidCode() gopter.Gen {
|
||||
// 生成 4 位小写字母字符串,永远不会匹配 [A-Z0-9]{6} 格式的有效 code
|
||||
return gen.SliceOfN(4, gen.RuneRange('a', 'z')).Map(func(runes []rune) string {
|
||||
return string(runes)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProperty7_InvalidCodeReturns404
|
||||
// 对于任意不存在的 code,GET /api/launch/{code} 应返回:
|
||||
// - HTTP 404
|
||||
// - Content-Type: application/json
|
||||
// - 响应体含 ok:false 和 error 字段
|
||||
func TestProperty7_InvalidCodeReturns404(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("不存在的 code 返回 404", prop.ForAll(
|
||||
func(code string) bool {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Type"), "application/json") {
|
||||
return false
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ok, _ := resp["ok"].(bool)
|
||||
_, hasError := resp["error"]
|
||||
return !ok && hasError
|
||||
},
|
||||
genInvalidCode(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// --- Property 8: 重复唤起的幂等性 ---
|
||||
|
||||
// TestProperty8_IdempotentLaunch
|
||||
// 对于已运行的实例,连续两次 GET /api/launch/{code}:
|
||||
// - 两次均返回 HTTP 200
|
||||
// - 两次返回的 pid 相同(不重新启动)
|
||||
func TestProperty8_IdempotentLaunch(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("重复唤起返回相同 pid,不重新启动", prop.ForAll(
|
||||
func(profileId string, pid int) bool {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: "test-profile",
|
||||
Pid: pid,
|
||||
DebugPort: 9222,
|
||||
Running: true,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
|
||||
code, err := svc.EnsureCode(profileId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
// 第一次请求
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w1, req1)
|
||||
|
||||
// 第二次请求
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w2, req2)
|
||||
|
||||
if w1.Code != http.StatusOK || w2.Code != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
var resp1, resp2 map[string]interface{}
|
||||
if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
pid1, _ := resp1["pid"].(float64)
|
||||
pid2, _ := resp2["pid"].(float64)
|
||||
|
||||
// 两次 pid 相同,且 StartInstance 被调用了 2 次(幂等由 starter 保证返回同一 profile)
|
||||
return pid1 == pid2 && pid1 == float64(pid)
|
||||
},
|
||||
genNonEmptyAlpha(),
|
||||
gen.IntRange(1000, 99999),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// --- 健康检查单元测试 ---
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
ok, _ := resp["ok"].(bool)
|
||||
if !ok {
|
||||
t.Error("期望 ok=true")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
func TestSetCodeAndResolveCaseInsensitive(t *testing.T) {
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
code, err := svc.SetCode("p1", "demo_code")
|
||||
if err != nil {
|
||||
t.Fatalf("SetCode 失败: %v", err)
|
||||
}
|
||||
if code != "DEMO_CODE" {
|
||||
t.Fatalf("期望 DEMO_CODE,实际 %s", code)
|
||||
}
|
||||
|
||||
profileID, err := svc.Resolve("demo_code")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve 失败: %v", err)
|
||||
}
|
||||
if profileID != "p1" {
|
||||
t.Fatalf("期望 p1,实际 %s", profileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCodeConflict(t *testing.T) {
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
if _, err := svc.SetCode("p1", "AAA111"); err != nil {
|
||||
t.Fatalf("SetCode p1 失败: %v", err)
|
||||
}
|
||||
if _, err := svc.SetCode("p2", "AAA111"); err == nil {
|
||||
t.Fatal("期望 code 冲突时报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCodeValidation(t *testing.T) {
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
cases := []string{"", "a", "ab", "中文123", "abc!123", strings.Repeat("A", 40)}
|
||||
for _, c := range cases {
|
||||
if _, err := svc.SetCode("p1", c); err == nil {
|
||||
t.Fatalf("期望非法 code 报错: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/proxy"
|
||||
)
|
||||
|
||||
// 模拟数据库中实际存储的 Clash YAML 格式代理配置
|
||||
var testTrojanConfig = `- name: HK01|香港|x1.0
|
||||
type: trojan
|
||||
server: trojan.example.com
|
||||
port: 443
|
||||
password: example-password
|
||||
udp: true
|
||||
skip-cert-verify: true
|
||||
network: tcp`
|
||||
|
||||
var testVmessConfig = `- name: DE-Vmess(NL1) 1x
|
||||
type: vmess
|
||||
server: 203.0.113.55
|
||||
port: 443
|
||||
uuid: 11111111-1111-4111-8111-111111111111
|
||||
alterId: 0
|
||||
cipher: auto
|
||||
udp: true
|
||||
tls: true
|
||||
skip-cert-verify: false
|
||||
servername: vmess.example.com
|
||||
network: ws
|
||||
ws-opts:
|
||||
path: /
|
||||
headers:
|
||||
Host: vmess.example.com`
|
||||
|
||||
var testHysteria2Config = `- name: Hysteria Japan Pluse | 0.1x
|
||||
server: hy2.example.com
|
||||
port: 443
|
||||
sni: hy2.example.com
|
||||
up: 102400
|
||||
down: 102400
|
||||
skip-cert-verify: true
|
||||
ports: 10800-10888
|
||||
type: hysteria2
|
||||
password: example-password`
|
||||
|
||||
func TestProtocolDetection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
}{
|
||||
{"trojan-clash", testTrojanConfig},
|
||||
{"vmess-clash", testVmessConfig},
|
||||
{"hysteria2-clash", testHysteria2Config},
|
||||
{"socks5-direct", "socks5://127.0.0.1:1080"},
|
||||
{"http-direct", "http://127.0.0.1:7890"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
src := strings.TrimSpace(tt.config)
|
||||
l := strings.ToLower(src)
|
||||
|
||||
isSingBox := proxy.IsSingBoxProtocol(src)
|
||||
requiresBridge := proxy.RequiresBridge(src, nil, "")
|
||||
isDirectHTTP := strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://")
|
||||
isDirectSocks := strings.HasPrefix(l, "socks5://")
|
||||
|
||||
fmt.Printf("\n=== %s ===\n", tt.name)
|
||||
fmt.Printf(" config前30字符: %q\n", src[:minInt(30, len(src))])
|
||||
fmt.Printf(" IsSingBoxProtocol: %v\n", isSingBox)
|
||||
fmt.Printf(" RequiresBridge: %v\n", requiresBridge)
|
||||
fmt.Printf(" isDirectHTTP: %v\n", isDirectHTTP)
|
||||
fmt.Printf(" isDirectSocks: %v\n", isDirectSocks)
|
||||
|
||||
// 测试 ParseProxyNode
|
||||
standardProxy, outbound, err := proxy.ParseProxyNode(src)
|
||||
fmt.Printf(" ParseProxyNode:\n")
|
||||
fmt.Printf(" standardProxy: %q\n", standardProxy)
|
||||
fmt.Printf(" outbound nil?: %v\n", outbound == nil)
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
|
||||
if !isSingBox && !requiresBridge && !isDirectHTTP && !isDirectSocks {
|
||||
t.Errorf("代理配置未被任何分支识别!会走到兜底逻辑")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedTestWithMockProxies(t *testing.T) {
|
||||
// 模拟 a.config.Browser.Proxies 的内容
|
||||
proxies := []config.BrowserProxy{
|
||||
{ProxyId: "test-trojan", ProxyName: "测试trojan", ProxyConfig: testTrojanConfig},
|
||||
{ProxyId: "test-vmess", ProxyName: "测试vmess", ProxyConfig: testVmessConfig},
|
||||
{ProxyId: "test-hysteria2", ProxyName: "测试hysteria2", ProxyConfig: testHysteria2Config},
|
||||
{ProxyId: "test-http", ProxyName: "测试http", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}
|
||||
|
||||
for _, p := range proxies {
|
||||
t.Run(p.ProxyName, func(t *testing.T) {
|
||||
// 不传 xrayMgr/singboxMgr,看会走到哪个分支
|
||||
result := proxy.SpeedTest(p.ProxyId, proxies, nil, nil, nil)
|
||||
fmt.Printf("\n=== SpeedTest %s ===\n", p.ProxyName)
|
||||
fmt.Printf(" Ok: %v\n", result.Ok)
|
||||
fmt.Printf(" LatencyMs: %d\n", result.LatencyMs)
|
||||
fmt.Printf(" Error: %q\n", result.Error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/proxy"
|
||||
)
|
||||
|
||||
func TestTrojanClashYAML(t *testing.T) {
|
||||
node := `- name: JP02|日本|x1.0
|
||||
type: trojan
|
||||
server: trojan.example.com
|
||||
port: 443
|
||||
password: example-password
|
||||
udp: true
|
||||
skip-cert-verify: true
|
||||
network: tcp`
|
||||
|
||||
standard, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if standard != "" {
|
||||
t.Fatalf("期望 outbound,得到 standard: %s", standard)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("trojan clash outbound:\n%s", string(data))
|
||||
|
||||
if outbound["protocol"] != "trojan" {
|
||||
t.Errorf("protocol 期望 trojan,得到 %v", outbound["protocol"])
|
||||
}
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["address"] != "trojan.example.com" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
if settings["password"] != "example-password" {
|
||||
t.Errorf("password 不匹配: %v", settings["password"])
|
||||
}
|
||||
stream := outbound["streamSettings"].(map[string]interface{})
|
||||
if stream["security"] != "tls" {
|
||||
t.Errorf("security 期望 tls,得到 %v", stream["security"])
|
||||
}
|
||||
tls := stream["tlsSettings"].(map[string]interface{})
|
||||
if tls["allowInsecure"] != true {
|
||||
t.Errorf("allowInsecure 期望 true,得到 %v", tls["allowInsecure"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrojanURI(t *testing.T) {
|
||||
node := "trojan://mypassword@example.com:443?sni=example.com&allowInsecure=1"
|
||||
_, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if outbound["protocol"] != "trojan" {
|
||||
t.Errorf("protocol 期望 trojan,得到 %v", outbound["protocol"])
|
||||
}
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["address"] != "example.com" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
if settings["password"] != "mypassword" {
|
||||
t.Errorf("password 不匹配: %v", settings["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSClashYAML(t *testing.T) {
|
||||
node := `- name: SS节点
|
||||
type: ss
|
||||
server: 1.2.3.4
|
||||
port: 8388
|
||||
cipher: aes-256-gcm
|
||||
password: testpassword`
|
||||
|
||||
_, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("SS clash outbound:\n%s", string(data))
|
||||
|
||||
if outbound["protocol"] != "shadowsocks" {
|
||||
t.Errorf("protocol 期望 shadowsocks,得到 %v", outbound["protocol"])
|
||||
}
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["address"] != "1.2.3.4" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
if settings["method"] != "aes-256-gcm" {
|
||||
t.Errorf("method 不匹配: %v", settings["method"])
|
||||
}
|
||||
if settings["password"] != "testpassword" {
|
||||
t.Errorf("password 不匹配: %v", settings["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSURI_SIP002(t *testing.T) {
|
||||
// SIP002: ss://BASE64(method:password)@host:port#name
|
||||
userInfo := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:mypassword"))
|
||||
node := fmt.Sprintf("ss://%s@1.2.3.4:8388#测试节点", userInfo)
|
||||
_, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("SS SIP002 outbound:\n%s", string(data))
|
||||
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["method"] != "aes-256-gcm" {
|
||||
t.Errorf("method 不匹配: %v", settings["method"])
|
||||
}
|
||||
if settings["password"] != "mypassword" {
|
||||
t.Errorf("password 不匹配: %v", settings["password"])
|
||||
}
|
||||
if settings["address"] != "1.2.3.4" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSURI_Legacy(t *testing.T) {
|
||||
// 旧格式: ss://BASE64(method:password@host:port)
|
||||
raw := base64.StdEncoding.EncodeToString([]byte("chacha20-ietf-poly1305:pass123@2.3.4.5:443"))
|
||||
node := "ss://" + raw
|
||||
_, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["method"] != "chacha20-ietf-poly1305" {
|
||||
t.Errorf("method 不匹配: %v", settings["method"])
|
||||
}
|
||||
if settings["address"] != "2.3.4.5" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
t.Logf("SS legacy outbound OK: %v", settings)
|
||||
}
|
||||
|
||||
func TestSSR_Unsupported(t *testing.T) {
|
||||
node := "ssr://somebase64data"
|
||||
_, _, err := proxy.ParseProxyNode(node)
|
||||
if err == nil {
|
||||
t.Fatal("期望 SSR 返回错误,但没有")
|
||||
}
|
||||
t.Logf("SSR 正确返回错误: %v", err)
|
||||
}
|
||||
|
||||
func TestHysteria2URI(t *testing.T) {
|
||||
node := "hysteria2://mypassword@example.com:443?sni=example.com&insecure=1"
|
||||
outbound, err := proxy.BuildSingBoxOutbound(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("hysteria2 URI outbound:\n%s", string(data))
|
||||
|
||||
if outbound["type"] != "hysteria2" {
|
||||
t.Errorf("type 期望 hysteria2,得到 %v", outbound["type"])
|
||||
}
|
||||
if outbound["server"] != "example.com" {
|
||||
t.Errorf("server 不匹配: %v", outbound["server"])
|
||||
}
|
||||
if outbound["password"] != "mypassword" {
|
||||
t.Errorf("password 不匹配: %v", outbound["password"])
|
||||
}
|
||||
tls := outbound["tls"].(map[string]interface{})
|
||||
if tls["insecure"] != true {
|
||||
t.Errorf("insecure 期望 true,得到 %v", tls["insecure"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHysteria2ClashYAML(t *testing.T) {
|
||||
node := `- name: HY2节点
|
||||
type: hysteria2
|
||||
server: example.com
|
||||
port: 443
|
||||
password: testpass
|
||||
sni: example.com
|
||||
skip-cert-verify: false`
|
||||
|
||||
outbound, err := proxy.BuildSingBoxOutbound(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("hysteria2 clash outbound:\n%s", string(data))
|
||||
|
||||
if outbound["type"] != "hysteria2" {
|
||||
t.Errorf("type 期望 hysteria2,得到 %v", outbound["type"])
|
||||
}
|
||||
if outbound["server"] != "example.com" {
|
||||
t.Errorf("server 不匹配: %v", outbound["server"])
|
||||
}
|
||||
tls := outbound["tls"].(map[string]interface{})
|
||||
if tls["server_name"] != "example.com" {
|
||||
t.Errorf("server_name 不匹配: %v", tls["server_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingBoxProtocol(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"hysteria2://pass@host:443", true},
|
||||
{"hysteria://pass@host:443", true},
|
||||
{"- name: n\n type: hysteria2\n server: h\n port: 443", true},
|
||||
{"- name: n\n type: tuic\n server: h\n port: 443", true},
|
||||
{"vmess://xxx", false},
|
||||
{"trojan://pass@host:443", false},
|
||||
{"socks5://127.0.0.1:1080", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := proxy.IsSingBoxProtocol(c.input)
|
||||
if got != c.expected {
|
||||
t.Errorf("IsSingBoxProtocol(%q) = %v, 期望 %v", c.input[:min(30, len(c.input))], got, c.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user