mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
265 lines
6.3 KiB
Go
265 lines
6.3 KiB
Go
package testsuite
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func (r *Runner) prepareRunDir() error {
|
|
r.runDir = filepath.Join(r.opts.OutputDir, r.runID)
|
|
if err := os.MkdirAll(r.runDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(r.runDir, "cases"), 0o755); err != nil {
|
|
return err
|
|
}
|
|
r.serverLog = filepath.Join(r.runDir, "server.log")
|
|
r.preflightLog = filepath.Join(r.runDir, "preflight.log")
|
|
return nil
|
|
}
|
|
|
|
// pruneOldRuns removes old test run directories, keeping the most recent MaxKeepRuns.
|
|
// Run IDs use the format "20060102T150405Z", so alphabetical order == chronological order.
|
|
func (r *Runner) pruneOldRuns() error {
|
|
keep := r.opts.MaxKeepRuns
|
|
if keep <= 0 {
|
|
return nil // 0 or negative means no pruning
|
|
}
|
|
|
|
entries, err := os.ReadDir(r.opts.OutputDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Collect only directories (each run is a directory).
|
|
var runDirs []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
runDirs = append(runDirs, e.Name())
|
|
}
|
|
|
|
sort.Strings(runDirs)
|
|
|
|
if len(runDirs) <= keep {
|
|
return nil
|
|
}
|
|
|
|
// Remove oldest runs (those at the beginning of the sorted list).
|
|
toRemove := runDirs[:len(runDirs)-keep]
|
|
var errs []string
|
|
for _, name := range toRemove {
|
|
dirPath := filepath.Join(r.opts.OutputDir, name)
|
|
if err := os.RemoveAll(dirPath); err != nil {
|
|
errs = append(errs, fmt.Sprintf("remove %s: %v", name, err))
|
|
} else {
|
|
fmt.Fprintf(os.Stdout, "pruned old test run: %s\n", name)
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return errors.New(strings.Join(errs, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Runner) runPreflight(ctx context.Context) error {
|
|
steps := preflightSteps()
|
|
f, err := os.OpenFile(r.preflightLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
for _, step := range steps {
|
|
if _, err := fmt.Fprintf(f, "\n$ %s\n", strings.Join(step, " ")); err != nil {
|
|
return err
|
|
}
|
|
cmd := exec.CommandContext(ctx, step[0], step[1:]...)
|
|
cmd.Stdout = f
|
|
cmd.Stderr = f
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("preflight failed at `%s`: %w", strings.Join(step, " "), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func preflightSteps() [][]string {
|
|
return [][]string{
|
|
{"go", "test", "./...", "-count=1"},
|
|
{"./tests/scripts/check-node-split-syntax.sh"},
|
|
{"node", "--test", "tests/node/stream-tool-sieve.test.js", "tests/node/chat-stream.test.js", "tests/node/js_compat_test.js"},
|
|
{"npm", "run", "build", "--prefix", "webui"},
|
|
}
|
|
}
|
|
|
|
func (r *Runner) prepareConfigIsolation() error {
|
|
abs, err := filepath.Abs(r.opts.ConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.originalConfigPath = abs
|
|
raw, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sum := sha256.Sum256(raw)
|
|
r.originalConfigHash = hex.EncodeToString(sum[:])
|
|
|
|
tmpDir := filepath.Join(r.runDir, "tmp")
|
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
r.configCopyPath = filepath.Join(tmpDir, "config.json")
|
|
if err := os.WriteFile(r.configCopyPath, raw, 0o644); err != nil {
|
|
return err
|
|
}
|
|
var cfg runConfig
|
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
|
return fmt.Errorf("parse config failed: %w", err)
|
|
}
|
|
r.configRaw = cfg
|
|
if len(cfg.Keys) > 0 {
|
|
r.apiKey = strings.TrimSpace(cfg.Keys[0])
|
|
}
|
|
for _, acc := range cfg.Accounts {
|
|
id := strings.TrimSpace(acc.Email)
|
|
if id == "" {
|
|
id = strings.TrimSpace(acc.Mobile)
|
|
}
|
|
if id != "" {
|
|
r.accountID = id
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Runner) startServer(ctx context.Context) error {
|
|
port := r.opts.Port
|
|
if port <= 0 {
|
|
p, err := findFreePort()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
port = p
|
|
}
|
|
r.baseURL = "http://127.0.0.1:" + strconv.Itoa(port)
|
|
|
|
logFd, err := os.OpenFile(r.serverLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.serverLogFd = logFd
|
|
cmd := exec.CommandContext(ctx, "go", "run", "./cmd/ds2api")
|
|
cmd.Stdout = logFd
|
|
cmd.Stderr = logFd
|
|
cmd.Env = prepareServerEnv(os.Environ(), map[string]string{
|
|
"PORT": strconv.Itoa(port),
|
|
"DS2API_CONFIG_PATH": r.configCopyPath,
|
|
"DS2API_AUTO_BUILD_WEBUI": "false",
|
|
"DS2API_CONFIG_JSON": "",
|
|
"CONFIG_JSON": "",
|
|
})
|
|
if err := cmd.Start(); err != nil {
|
|
_ = logFd.Close()
|
|
return err
|
|
}
|
|
r.serverCmd = cmd
|
|
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if r.ping("/healthz") == nil && r.ping("/readyz") == nil {
|
|
return nil
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
return errors.New("server readiness timeout")
|
|
}
|
|
|
|
func (r *Runner) stopServer() error {
|
|
var errs []string
|
|
if r.serverCmd != nil && r.serverCmd.Process != nil {
|
|
_ = r.serverCmd.Process.Signal(os.Interrupt)
|
|
done := make(chan error, 1)
|
|
go func() { done <- r.serverCmd.Wait() }()
|
|
select {
|
|
case <-time.After(5 * time.Second):
|
|
_ = r.serverCmd.Process.Kill()
|
|
<-done
|
|
case <-done:
|
|
}
|
|
}
|
|
if r.serverLogFd != nil {
|
|
if err := r.serverLogFd.Close(); err != nil {
|
|
errs = append(errs, err.Error())
|
|
}
|
|
}
|
|
if len(errs) > 0 {
|
|
return errors.New(strings.Join(errs, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Runner) ping(path string) error {
|
|
resp, err := r.httpClient.Get(r.baseURL + path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("status=%d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Runner) prepareAuth(ctx context.Context) error {
|
|
reqBody := map[string]any{
|
|
"admin_key": r.adminKey,
|
|
"expire_hours": 24,
|
|
}
|
|
resp, err := r.doSimpleJSON(ctx, http.MethodPost, "/admin/login", nil, reqBody)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("admin login status=%d body=%s", resp.StatusCode, string(resp.Body))
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(resp.Body, &m); err != nil {
|
|
return err
|
|
}
|
|
token, _ := m["token"].(string)
|
|
if strings.TrimSpace(token) == "" {
|
|
return errors.New("empty admin jwt token")
|
|
}
|
|
r.adminJWT = token
|
|
return nil
|
|
}
|
|
|
|
func (r *Runner) ensureOriginalConfigUntouched() error {
|
|
raw, err := os.ReadFile(r.originalConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sum := sha256.Sum256(raw)
|
|
current := hex.EncodeToString(sum[:])
|
|
if current != r.originalConfigHash {
|
|
return fmt.Errorf("original config changed unexpectedly: %s", r.originalConfigPath)
|
|
}
|
|
return nil
|
|
}
|