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:
Ant Browser Release Bot
2026-03-13 23:19:29 +08:00
commit 6f58a6c19a
230 changed files with 44624 additions and 0 deletions
+199
View File
@@ -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 和 LaunchCodeUpsert 后:
// - 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,实际 %dbody=%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)
}
}
+274
View File
@@ -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
// 对于任意存在的 LaunchCodeGET /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
// 对于任意不存在的 codeGET /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)
}
}
}