test: 添加 FFmpeg 异步初始化 e2e 测试,并修复 code review 发现的两个 Bug

## e2e 测试基础设施

### 测试辅助工具
- test/fake-ffmpeg/main.go: 极简 FFmpeg 替身程序,响应 -version 参数
- test/ffmpeg-mock-server/main.go: 模拟 FFmpeg 下载服务器
  - 启动时自动编译 fake-ffmpeg 并打包成 zip(含 bin/ffmpeg.exe 路径)
  - GET /ffmpeg.zip: 支持 ?speed=<字节/秒> 限速和 ?fail=true 失败模拟
  - POST /control: 运行时动态修改限速/失败行为
  - GET /health: 等待 zip 就绪后返回 200(供 Playwright 做启动检查)

### 测试 Fixture
- tests/e2e/fixtures/test-tools-config.json: remotetools 测试配置,将所有
  平台的 FFmpeg 下载 URL 重定向至 127.0.0.1:8890/ffmpeg.zip
- tests/e2e/fixtures/ffmpeg-test-config.template.yml: bgo 测试配置
  - 端口 8085(避免与正常测试冲突)
  - ffmpeg_path 指向不存在路径(强制走 remotetools 下载流程)
  - 独立 app_data_path(避免与正常测试的缓存互相干扰)

### 后端调试注入端点(dev 构建专有)
- src/servers/debug_routes_dev.go: POST /api/debug/ffmpeg/force-state
  供 e2e 测试注入任意 FFmpeg 状态,无需实际触发下载
- src/servers/debug_routes_stub.go: 非 dev 构建的空实现
- src/servers/server.go: 注册 registerDevDebugRoutes(apiRoute)
- src/tools/tools.go: 导出 ForceFFmpegStatus() 供调试端点调用
- src/tools/dev_config.go: 支持 REMOTETOOLS_CONFIG 环境变量覆盖
  remotetools 配置路径,使下载流程测试可指向 mock server

### E2E 测试文件
- tests/e2e/ffmpeg-status.spec.ts: 9 条状态注入测试(在正常配置下运行)
  - 验证 ready/checking/downloading/not_found/error 各状态的 Banner 显示
  - 验证状态切换后 Banner 正确消失
  - 验证 GET /api/ffmpeg/status 返回格式和 Alert 类型
- tests/e2e/ffmpeg-download.spec.ts: 3 条完整下载流程测试(需 mock server)
  - 慢速下载时出现 downloading Banner,加速后 Banner 消失
  - 下载失败时显示 error Banner
  - 页面刷新后正确恢复当前状态
- playwright.ffmpeg.config.ts: 下载流程测试专用配置
  - 运行 ffmpeg-mock-server 和使用独立 bgo 实例(端口 8085)
  - 通过 REMOTETOOLS_CONFIG 环境变量注入测试用 remotetools 配置
  - 每次运行前清空 external_tools 目录,确保测试从干净状态开始
- playwright.config.ts: 新增 testIgnore 排除 ffmpeg-download.spec.ts
  (该文件需要 mock server,仅在 playwright.ffmpeg.config.ts 下运行)

## Bug 修复(对应 PR #1150 code review)

### Fix 1: WaitForToolsInit 错误分支死代码(tools.go)
原代码将 WaitForToolsInit 返回的所有非 nil 错误都当作 ctx 取消处理,
但 WaitForToolsInit 在 Init() 失败时也返回该错误,导致:
- FFmpeg 状态永久停留在 "checking"
- not_found/error 状态永远不会被设置
修复:通过 ctx.Err() != nil 区分 ctx 取消和 Init 失败,失败时
正确调用 setFFmpegStatus({State: "not_found"})

### Fix 2: 前端竞态条件(ffmpeg-banner/index.tsx)
原代码将 handleSSEMessage 定义在 useEffect 外部(useCallback),
导致:HTTP 初始化请求如果晚于 SSE 推送到达,会用旧状态覆盖新状态,
使 Banner 重新出现并永久卡在旧状态;且组件卸载后 HTTP 回调仍会
调用 setStatus 造成内存泄漏。
修复:
- 将 handler 移入 useEffect,添加 active flag 防止卸载后更新
- 添加 sseReceived flag,HTTP 响应到达时若已收到 SSE 则忽略
- 移除不再需要的 useCallback import

### Fix 3: FFmpegAsyncInit 过早访问 remotetools(tools.go)
原注释称"先检查系统 PATH / 配置文件中的 FFmpeg,不需要 remotetools",
但实际调用 utils.IsFFmpegExist → utils.GetFFmpegPath → remotetools.Get().GetTool(),
在 Init() 完成前访问未配置好的 remotetools,可能产生误判。
修复:新增 isFFmpegAvailableWithoutRemotetools() 仅检查配置路径
(os.Stat)和系统 PATH(exec.LookPath),不触碰 remotetools。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kira1928andClaude Sonnet 4.6 committed 2026-06-22 01:02:30 +09:00
1 parent 47f6cf3dd9
commit 13c32ff68d
14 files changed
+804 -20

No files matched your search

+2
View File
@@ -23,6 +23,8 @@ fs.copyFileSync(configTemplate, configRuntime);
*/
export default defineConfig({
testDir: './tests/e2e',
// ffmpeg-download.spec.ts 需要独立的 mock server,仅在 playwright.ffmpeg.config.ts 中运行
testIgnore: ['**/ffmpeg-download.spec.ts'],
// 测试输出目录(被 .gitignore 忽略)
outputDir: 'test-results',
+106
View File
@@ -0,0 +1,106 @@
/**
* playwright.ffmpeg.config.ts
*
* FFmpeg 异步下载流程 e2e 测试专用配置。
*
* 与普通 playwright.config.ts 的区别:
* - bgo 运行在 8085 端口(避免端口冲突)
* - bgo 配置 ffmpeg_path 指向不存在路径,强制走 remotetools 下载流程
* - 通过 REMOTETOOLS_CONFIG 环境变量将 remotetools 重定向到本地 mock server
* - 额外启动 ffmpeg-mock-server 在 8890 端口,提供可控的 FFmpeg 下载
* - 只运行 ffmpeg-download.spec.ts(状态注入测试在普通配置中运行)
*/
import { defineConfig, devices } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
const configTemplate = path.join(
__dirname,
'tests/e2e/fixtures/ffmpeg-test-config.template.yml',
);
const configRuntime = path.join(__dirname, 'test-output/ffmpeg-test-config.yml');
const toolsConfigPath = path.join(
__dirname,
'tests/e2e/fixtures/test-tools-config.json',
).replace(/\\/g, '/');
// 确保 test-output 目录存在并准备配置文件
fs.mkdirSync(path.dirname(configRuntime), { recursive: true });
fs.copyFileSync(configTemplate, configRuntime);
// 清空 FFmpeg e2e 测试专用的 appdata 目录,避免上次测试缓存的 fake-ffmpeg 影响结果
const ffmpegAppData = path.join(__dirname, 'test-output/.appdata-ffmpeg-e2e/external_tools');
try {
fs.rmSync(ffmpegAppData, { recursive: true, force: true });
} catch (_) {
// 目录不存在时忽略
}
// 构建启动 bgo 的命令(需设置 REMOTETOOLS_CONFIG 环境变量)
function makeBgoCommand(): string {
const configArg = `test-output/ffmpeg-test-config.yml`;
if (process.platform === 'win32') {
// PowerShell 语法设置环境变量
return (
`powershell -Command "$env:REMOTETOOLS_CONFIG='${toolsConfigPath}'; ` +
`go run -tags dev ./src/cmd/bililive --config ${configArg}"`
);
}
// bash/sh 语法
return `REMOTETOOLS_CONFIG='${toolsConfigPath}' go run -tags dev ./src/cmd/bililive --config ${configArg}`;
}
export default defineConfig({
// 只运行 FFmpeg 下载流程专用的测试文件
testDir: './tests/e2e',
testMatch: ['**/ffmpeg-download.spec.ts'],
outputDir: 'test-results-ffmpeg',
timeout: 120 * 1000, // 下载测试需要更长超时
expect: { timeout: 10 * 1000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: [
['list'],
['html', { outputFolder: 'playwright-report-ffmpeg', open: 'never' }],
],
use: {
baseURL: 'http://127.0.0.1:8085',
screenshot: 'only-on-failure',
trace: { mode: 'on', sources: true, snapshots: true, screenshots: true },
video: 'retain-on-failure',
viewport: { width: 1280, height: 720 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: [
// ffmpeg-mock-server — 提供可控的 FFmpeg zip 下载
{
command: 'go run ./test/ffmpeg-mock-server -port 8890',
url: 'http://127.0.0.1:8890/health',
reuseExistingServer: false,
timeout: 120 * 1000, // 等待 fake-ffmpeg 编译完成
stdout: 'pipe',
stderr: 'pipe',
},
// bgo — 无可用 FFmpeg,remotetools 指向 mock server
{
command: makeBgoCommand(),
url: 'http://127.0.0.1:8085/api/info',
reuseExistingServer: false,
timeout: 60 * 1000,
stdout: 'pipe',
stderr: 'pipe',
},
],
});
+29
View File
@@ -0,0 +1,29 @@
//go:build dev
package servers
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/bililive-go/bililive-go/src/tools"
)
func registerDevDebugRoutes(apiRoute *mux.Router) {
// POST /api/debug/ffmpeg/force-state — 强制设置 FFmpeg 状态,仅用于 e2e 测试
apiRoute.HandleFunc("/debug/ffmpeg/force-state", forceFFmpegStateHandler).Methods("POST")
}
func forceFFmpegStateHandler(w http.ResponseWriter, r *http.Request) {
var req tools.FFmpegStatus
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body: "+err.Error(), http.StatusBadRequest)
return
}
tools.ForceFFmpegStatus(req)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !dev
package servers
import "github.com/gorilla/mux"
func registerDevDebugRoutes(apiRoute *mux.Router) {}
+3
View File
@@ -143,6 +143,9 @@ func initMux(ctx context.Context) *mux.Router {
// FFmpeg 状态 API 路由
apiRoute.HandleFunc("/ffmpeg/status", getFFmpegStatusHandler).Methods("GET")
// 测试专用调试路由(dev 构建标签时注册,生产构建为空操作)
registerDevDebugRoutes(apiRoute)
// OpenList (云上传) API 路由
apiRoute.HandleFunc("/openlist/status", getOpenListStatus).Methods("GET")
apiRoute.HandleFunc("/openlist/check-storage", checkOpenListStorageHealth).Methods("GET")
+4
View File
@@ -7,5 +7,9 @@ import (
)
func getConfigData() (data []byte, err error) {
// 允许测试通过环境变量覆盖 remotetools 配置路径
if override := os.Getenv("REMOTETOOLS_CONFIG"); override != "" {
return os.ReadFile(override)
}
return os.ReadFile("src/tools/remote-tools-config.json")
}
+28 -8
View File
@@ -107,13 +107,32 @@ func OnFFmpegStatusChange(fn func(FFmpegStatus)) {
ffmpegCbMu.Unlock()
}
// ForceFFmpegStatus 强制设置 FFmpeg 状态,仅用于测试目的。
func ForceFFmpegStatus(s FFmpegStatus) {
setFFmpegStatus(s)
}
// isFFmpegAvailableWithoutRemotetools 仅检查配置路径和系统 PATH,
// 不访问 remotetools(避免在 Init() 完成前触及未配置好的 remotetools 状态)。
func isFFmpegAvailableWithoutRemotetools() bool {
if cfg := configs.GetCurrentConfig(); cfg != nil {
if path := strings.TrimSpace(cfg.FfmpegPath); path != "" {
if _, err := os.Stat(path); err == nil {
return true
}
}
}
_, err := exec.LookPath("ffmpeg")
return err == nil
}
// FFmpegAsyncInit 异步检测并(按需)下载 FFmpeg,期间通过状态回调通知进度。
// 可在 WebUI 启动后立即调用,不会阻塞主流程。
func FFmpegAsyncInit(ctx context.Context) {
setFFmpegStatus(FFmpegStatus{State: "checking"})
bilisentry.GoWithContext(ctx, func(ctx context.Context) {
// 先检查系统 PATH / 配置文件中的 FFmpeg,不需要 remotetools
if utils.IsFFmpegExist(ctx) {
// 先仅检查配置路径与系统 PATH,不触碰 remotetools(此时 Init() 尚未完成)
if isFFmpegAvailableWithoutRemotetools() {
blog.GetLogger().Info("FFmpeg found in system PATH or config")
setFFmpegStatus(FFmpegStatus{State: "ready", Source: "system"})
return
@@ -122,16 +141,17 @@ func FFmpegAsyncInit(ctx context.Context) {
// 触发 remotetools 初始化(可能是 no-op,但确保 goroutine 在跑)
_ = Init()
// 等待 Init() 真正完成
// 等待 Init() 真正完成。WaitForToolsInit 在 Init() 失败时返回该错误,
// 在 ctx 取消时返回 ctx.Err(),需分别处理。
if err := WaitForToolsInit(ctx); err != nil {
// ctx 已取消(程序退出)
return
if ctx.Err() != nil {
return // 程序退出,不更新状态
}
if toolsInitError != nil {
blog.GetLogger().WithError(toolsInitError).Warn("remotetools init failed, FFmpeg not available")
// Init() 本身失败(如配置读取错误或 remotetools WebUI 启动失败)
blog.GetLogger().WithError(err).Warn("remotetools init failed, FFmpeg not available")
setFFmpegStatus(FFmpegStatus{
State: "not_found",
Message: "FFmpeg 未找到,且 remotetools 初始化失败: " + toolsInitError.Error(),
Message: "FFmpeg 未找到,且 remotetools 初始化失败: " + err.Error(),
})
return
}
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect } from 'react';
import { Alert, Space, Typography } from 'antd';
import {
SyncOutlined,
@@ -21,23 +21,31 @@ interface FFmpegStatus {
const FFmpegBanner: React.FC = () => {
const [status, setStatus] = useState<FFmpegStatus | null>(null);
const handleSSEMessage = useCallback((message: SSEMessage) => {
if (message.type === 'ffmpeg_status') {
setStatus(message.data as FFmpegStatus);
}
}, []);
useEffect(() => {
// 初始化时查询当前状态
let active = true;
let sseReceived = false;
const handleSSEMessage = (message: SSEMessage) => {
if (message.type === 'ffmpeg_status') {
sseReceived = true;
if (active) setStatus(message.data as FFmpegStatus);
}
};
const subId = subscribeSSE('*', 'ffmpeg_status', handleSSEMessage);
// 初始化时查询当前状态;若 SSE 已推送更新则忽略(避免旧 HTTP 响应覆盖新状态)
api.getFFmpegStatus().then((res: any) => {
if (res && res.state) {
if (active && !sseReceived && res?.state) {
setStatus(res as FFmpegStatus);
}
}).catch(() => {});
const subId = subscribeSSE('*', 'ffmpeg_status', handleSSEMessage);
return () => unsubscribeSSE(subId);
}, [handleSSEMessage]);
return () => {
active = false;
unsubscribeSSE(subId);
};
}, []);
// FFmpeg 已就绪或状态未知时不显示横幅
if (!status || status.state === 'ready') {
+20
View File
@@ -0,0 +1,20 @@
// fake-ffmpeg 是用于 e2e 测试的极简 FFmpeg 替身。
// 它响应 -version 参数,其余情况返回非零退出码。
package main
import (
"fmt"
"os"
)
func main() {
for _, arg := range os.Args[1:] {
if arg == "-version" {
fmt.Println("ffmpeg version fake-test-1.0.0-e2e")
fmt.Println("built with bililive-go test suite")
os.Exit(0)
}
}
fmt.Fprintln(os.Stderr, "fake-ffmpeg: this is a stub used only for e2e testing")
os.Exit(1)
}
+230
View File
@@ -0,0 +1,230 @@
// ffmpeg-mock-server 是一个用于 e2e 测试的模拟 FFmpeg 下载服务器。
//
// 它在启动时编译 test/fake-ffmpeg,将其打包成 zip 压缩包,
// 并通过 HTTP 提供下载,支持速度限制和失败模拟以测试各种场景。
//
// API:
//
// GET /health — 健康检查(等待 zip 准备好再返回 200)
// GET /ffmpeg.zip — 下载 fake-ffmpeg zip(?speed=<字节/秒>, ?fail=true)
// POST /control — 运行时修改行为 {"speed": N, "fail": true/false}
package main
import (
"archive/zip"
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"sync/atomic"
"time"
)
var (
port = flag.Int("port", 8890, "监听端口")
// 全局控制状态
globalSpeedBytesPerSec atomic.Int64 // 0 = 不限速
globalFail atomic.Bool
zipData []byte
zipReady = make(chan struct{})
zipBuildErr error
)
func main() {
flag.Parse()
go buildZip()
mux := http.NewServeMux()
mux.HandleFunc("/health", handleHealth)
mux.HandleFunc("/ffmpeg.zip", handleDownload)
mux.HandleFunc("/control", handleControl)
addr := fmt.Sprintf("127.0.0.1:%d", *port)
log.Printf("[ffmpeg-mock] 启动服务器: http://%s", addr)
server := &http.Server{Addr: addr, Handler: mux}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[ffmpeg-mock] 服务器错误: %v", err)
}
}
func buildZip() {
defer close(zipReady)
data, err := buildFakeFfmpegZip()
if err != nil {
zipBuildErr = err
log.Printf("[ffmpeg-mock] 构建 fake-ffmpeg zip 失败: %v", err)
return
}
zipData = data
log.Printf("[ffmpeg-mock] fake-ffmpeg zip 已就绪,大小: %d 字节", len(data))
}
// handleHealth 等待 zip 就绪后返回 200,供 Playwright 做启动检查
func handleHealth(w http.ResponseWriter, r *http.Request) {
select {
case <-zipReady:
case <-time.After(120 * time.Second):
http.Error(w, "zip build timeout", http.StatusServiceUnavailable)
return
}
if zipBuildErr != nil {
http.Error(w, "zip build failed: "+zipBuildErr.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}
// handleDownload 提供 fake-ffmpeg zip 下载,支持速度限制和失败模拟
func handleDownload(w http.ResponseWriter, r *http.Request) {
<-zipReady
if zipBuildErr != nil {
http.Error(w, "zip not available: "+zipBuildErr.Error(), http.StatusInternalServerError)
return
}
speed := globalSpeedBytesPerSec.Load()
fail := globalFail.Load()
if s := r.URL.Query().Get("speed"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
speed = v
}
}
if r.URL.Query().Get("fail") == "true" {
fail = true
}
if fail {
// 返回 500 模拟下载失败,让 remotetools Install() 报错
http.Error(w, "simulated download failure", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="ffmpeg.zip"`)
w.Header().Set("Content-Length", strconv.Itoa(len(zipData)))
w.WriteHeader(http.StatusOK)
if speed <= 0 {
w.Write(zipData)
return
}
// 限速发送
const chunkSize = 4096
data := zipData
for len(data) > 0 {
n := chunkSize
if n > len(data) {
n = len(data)
}
w.Write(data[:n])
data = data[n:]
if flusher, ok2 := w.(http.Flusher); ok2 {
flusher.Flush()
}
if len(data) > 0 {
delay := time.Duration(float64(n) / float64(speed) * float64(time.Second))
time.Sleep(delay)
}
}
}
type controlRequest struct {
Speed *int64 `json:"speed"` // nil = 不修改
Fail *bool `json:"fail"` // nil = 不修改
}
func handleControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "only POST", http.StatusMethodNotAllowed)
return
}
var req controlRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body: "+err.Error(), http.StatusBadRequest)
return
}
if req.Speed != nil {
globalSpeedBytesPerSec.Store(*req.Speed)
}
if req.Fail != nil {
globalFail.Store(*req.Fail)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(fmt.Sprintf(`{"speed":%d,"fail":%v}`,
globalSpeedBytesPerSec.Load(), globalFail.Load())))
}
// buildFakeFfmpegZip 编译 test/fake-ffmpeg 并打包成 zip
func buildFakeFfmpegZip() ([]byte, error) {
// 支持从项目根或 test/ffmpeg-mock-server 目录运行
fakeFfmpegPkg := "./test/fake-ffmpeg"
if _, err := os.Stat("test/fake-ffmpeg"); os.IsNotExist(err) {
fakeFfmpegPkg = "../fake-ffmpeg"
}
tmpDir, err := os.MkdirTemp("", "ffmpeg-mock-*")
if err != nil {
return nil, fmt.Errorf("创建临时目录失败: %w", err)
}
defer os.RemoveAll(tmpDir)
binaryName := "ffmpeg"
if runtime.GOOS == "windows" {
binaryName = "ffmpeg.exe"
}
outBinary := filepath.Join(tmpDir, binaryName)
args := []string{"build", "-o", outBinary, fakeFfmpegPkg}
cmd := exec.Command("go", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Printf("[ffmpeg-mock] 编译 fake-ffmpeg: go %v", args)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("编译 fake-ffmpeg 失败: %w", err)
}
binaryData, err := os.ReadFile(outBinary)
if err != nil {
return nil, fmt.Errorf("读取编译产物失败: %w", err)
}
// zip 中的路径和 remotetools config 中的 pathToEntry 保持一致
entryPath := "bin/ffmpeg"
if runtime.GOOS == "windows" {
entryPath = "bin/ffmpeg.exe"
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
header := &zip.FileHeader{
Name: entryPath,
Method: zip.Deflate,
}
header.SetMode(0o755)
fw, err := zw.CreateHeader(header)
if err != nil {
return nil, fmt.Errorf("创建 zip 条目失败: %w", err)
}
if _, err = fw.Write(binaryData); err != nil {
return nil, fmt.Errorf("写入 zip 条目失败: %w", err)
}
if err = zw.Close(); err != nil {
return nil, fmt.Errorf("关闭 zip 写入器失败: %w", err)
}
return buf.Bytes(), nil
}
+124
View File
@@ -0,0 +1,124 @@
/**
* ffmpeg-download.spec.ts
*
* 测试 FFmpeg 异步下载流程的完整 UI 行为。
*
* 需要 playwright.ffmpeg.config.ts 配置(bgo 在 8085 端口运行,没有 FFmpeg 可用,
* remotetools 配置指向本地 ffmpeg-mock-server)。
*
* 关键特点:
* - bgo 启动时 ffmpeg_path 指向不存在的路径,触发 remotetools 下载
* - ffmpeg-mock-server 在 8890 端口运行,提供可控下载速度和失败模拟
* - 测试验证 Banner 在 downloading → ready 整个流程中的正确行为
*/
import { test, expect, Page } from '@playwright/test';
const BGO_BASE = 'http://127.0.0.1:8085';
const MOCK_SERVER = 'http://127.0.0.1:8890';
function ffmpegBanner(page: Page) {
return page.locator('.ffmpeg-banner');
}
/** 设置 mock server 的行为 */
async function setMockServerControl(
request: import('@playwright/test').APIRequestContext,
opts: { speed?: number; fail?: boolean },
) {
const resp = await request.post(`${MOCK_SERVER}/control`, { data: opts });
expect(resp.status()).toBe(200);
}
/** 轮询 /api/ffmpeg/status 直到匹配目标状态,返回最终状态对象 */
async function pollFFmpegStatus(
request: import('@playwright/test').APIRequestContext,
targetState: string,
timeoutMs = 60_000,
): Promise<{ state: string; message?: string; source?: string }> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resp = await request.get(`${BGO_BASE}/api/ffmpeg/status`);
if (resp.ok()) {
const body = await resp.json();
if (body.state === targetState) {
return body;
}
// 如果状态跳过了预期状态(如直接到 ready),也终止
if (body.state === 'error' && targetState !== 'error') {
throw new Error(`FFmpeg 进入 error 状态(预期 ${targetState}): ${body.message}`);
}
}
await new Promise((r) => setTimeout(r, 300));
}
const last = await (await request.get(`${BGO_BASE}/api/ffmpeg/status`)).json();
throw new Error(
`等待 FFmpeg 状态 "${targetState}" 超时(${timeoutMs}ms),当前状态: ${JSON.stringify(last)}`,
);
}
test.describe('FFmpeg 异步下载流程', () => {
test('下载中时 Banner 显示 downloading 状态,下载完成后 Banner 消失', async ({
page,
request,
}) => {
// 设置 mock server 为慢速(1KB/s),让下载过程持续足够长以供观察
await setMockServerControl(request, { speed: 1024, fail: false });
await page.goto(BGO_BASE + '/');
await page.waitForLoadState('networkidle');
// 等待出现 downloading 状态(下载启动后 UI 应显示进度横幅)
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 30_000 });
await expect(ffmpegBanner(page)).toContainText('正在下载 FFmpeg');
// 验证 API 也返回 downloading 状态
const dlStatus = await pollFFmpegStatus(request, 'downloading', 30_000);
expect(dlStatus.state).toBe('downloading');
// 切换为不限速让下载快速完成
await setMockServerControl(request, { speed: 0 });
// 等待下载完成,Banner 应消失(状态变为 ready)
await expect(ffmpegBanner(page)).not.toBeVisible({ timeout: 60_000 });
// 验证 API 状态也为 ready
const readyStatus = await pollFFmpegStatus(request, 'ready', 10_000);
expect(readyStatus.state).toBe('ready');
});
test('下载失败时 Banner 显示 error 状态', async ({ page, request }) => {
// 设置 mock server 为失败模式
await setMockServerControl(request, { fail: true, speed: 0 });
await page.goto(BGO_BASE + '/');
await page.waitForLoadState('networkidle');
// 等待 error 状态出现
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 30_000 });
await expect(ffmpegBanner(page)).toContainText('FFmpeg 下载失败');
// 验证 API 也返回 error 状态
const status = await pollFFmpegStatus(request, 'error', 30_000);
expect(status.state).toBe('error');
expect(status.message).toBeTruthy();
});
test('页面刷新后能正确恢复当前 FFmpeg 状态', async ({ page, request }) => {
// 先让 bgo 处于任意非 ready 状态(如果可以查到)
const initialStatus = await request.get(`${BGO_BASE}/api/ffmpeg/status`);
expect(initialStatus.ok()).toBeTruthy();
const status = await initialStatus.json();
// 刷新页面
await page.goto(BGO_BASE + '/');
await page.waitForLoadState('networkidle');
if (status.state === 'ready') {
// ready 状态:Banner 不应显示
await expect(ffmpegBanner(page)).not.toBeVisible({ timeout: 3000 });
} else {
// 非 ready 状态:Banner 应显示对应内容
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 5000 });
}
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* ffmpeg-status.spec.ts
*
* 测试 FFmpeg 状态 Banner 的 UI 行为。
* 使用 /api/debug/ffmpeg/force-state 端点(dev 构建专有)注入各种状态,
* 验证 Banner 的显示、内容和消失逻辑是否正确。
*
* 依赖:正常 playwright.config.ts(bgo 在 8080 端口运行,带 -tags dev)
*/
import { test, expect, Page, APIRequestContext } from '@playwright/test';
const BASE_URL = 'http://127.0.0.1:8080';
/**
* 通过 debug 端点强制设置 FFmpeg 状态,并等待 UI 响应
*/
async function forceFFmpegState(
request: APIRequestContext,
state: 'checking' | 'downloading' | 'ready' | 'not_found' | 'error',
message?: string,
source?: string,
) {
const body: Record<string, string> = { state };
if (message) body['message'] = message;
if (source) body['source'] = source;
const resp = await request.post(`${BASE_URL}/api/debug/ffmpeg/force-state`, { data: body });
expect(resp.status()).toBe(200);
}
/**
* 等待并返回 FFmpeg Banner 元素(可见状态下)
*/
function ffmpegBanner(page: Page) {
return page.locator('.ffmpeg-banner');
}
test.describe('FFmpeg 状态 Banner', () => {
test.beforeEach(async ({ page, request }) => {
// 先将状态重置为 ready,确保页面初始加载时 Banner 不可见
await forceFFmpegState(request, 'ready');
await page.goto('/');
// 等待页面基本加载完成
await page.waitForLoadState('networkidle');
// Banner 应为隐藏
await expect(ffmpegBanner(page)).not.toBeVisible();
});
test.afterEach(async ({ request }) => {
// 测试后恢复 ready 状态,不影响其他测试
await forceFFmpegState(request, 'ready');
});
test('ready 状态时 Banner 不显示', async ({ page }) => {
await expect(ffmpegBanner(page)).not.toBeVisible();
});
test('checking 状态时显示正在检测提示', async ({ page, request }) => {
await forceFFmpegState(request, 'checking');
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 5000 });
await expect(ffmpegBanner(page)).toContainText('正在检测 FFmpeg');
});
test('downloading 状态时显示下载进度提示', async ({ page, request }) => {
await forceFFmpegState(
request,
'downloading',
'正在从 GitHub 下载 FFmpeg...',
'remotetools',
);
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 5000 });
await expect(ffmpegBanner(page)).toContainText('正在下载 FFmpeg');
await expect(ffmpegBanner(page)).toContainText('正在从 GitHub 下载 FFmpeg...');
});
test('not_found 状态时显示警告提示', async ({ page, request }) => {
await forceFFmpegState(request, 'not_found', 'FFmpeg 未找到');
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 5000 });
await expect(ffmpegBanner(page)).toContainText('未找到 FFmpeg');
});
test('error 状态时显示错误提示', async ({ page, request }) => {
await forceFFmpegState(request, 'error', '下载超时');
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 5000 });
await expect(ffmpegBanner(page)).toContainText('FFmpeg 下载失败');
await expect(ffmpegBanner(page)).toContainText('下载超时');
});
test('从 downloading 切换到 ready 后 Banner 消失', async ({ page, request }) => {
// 先注入 downloading 状态
await forceFFmpegState(request, 'downloading', '模拟下载中...');
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 5000 });
// 再切换到 ready — Banner 应消失
await forceFFmpegState(request, 'ready');
await expect(ffmpegBanner(page)).not.toBeVisible({ timeout: 5000 });
});
test('从 error 切换到 ready 后 Banner 消失', async ({ page, request }) => {
await forceFFmpegState(request, 'error', '模拟下载失败');
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 5000 });
await forceFFmpegState(request, 'ready');
await expect(ffmpegBanner(page)).not.toBeVisible({ timeout: 5000 });
});
test('GET /api/ffmpeg/status 返回正确格式', async ({ request }) => {
await forceFFmpegState(request, 'downloading', '测试中', 'remotetools');
const resp = await request.get(`${BASE_URL}/api/ffmpeg/status`);
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body).toHaveProperty('state', 'downloading');
expect(body).toHaveProperty('message', '测试中');
expect(body).toHaveProperty('source', 'remotetools');
});
test('Banner 类型:downloading/checking 为 info,not_found 为 warning,error 为 error', async ({
page,
request,
}) => {
// downloading → info(蓝色横幅)
await forceFFmpegState(request, 'downloading');
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 5000 });
await expect(ffmpegBanner(page)).toHaveClass(/ant-alert-info/);
// not_found → warning(黄色横幅)
await forceFFmpegState(request, 'not_found');
await expect(ffmpegBanner(page)).toHaveClass(/ant-alert-warning/, { timeout: 5000 });
// error → error(红色横幅)
await forceFFmpegState(request, 'error', '测试失败');
await expect(ffmpegBanner(page)).toHaveClass(/ant-alert-error/, { timeout: 5000 });
});
});
@@ -0,0 +1,69 @@
# bililive-go 配置 — FFmpeg e2e 测试专用
# ffmpeg_path 指向一个不存在的路径,以确保程序走 remotetools 下载路径
rpc:
enable: true
bind: 127.0.0.1:8085
debug: false
interval: 5
out_put_path: ./test-output
ffmpeg_path: "./test-output/nonexistent-ffmpeg-for-e2e-test"
log:
out_put_folder: ./
save_last_log: true
save_every_log: false
rotate_days: 7
feature:
remove_symbol_other_character: false
out_put_tmpl: '{{.RoomName}}-{{.HostName}}/{{.StartTime}}'
video_split_strategies:
on_room_name_changed: false
max_duration: 0s
max_file_size: "0"
on_record_finished:
convert_to_mp4: false
delete_flv_after_convert: false
custom_commandline: ""
fix_flv_at_first: true
save_cover: false
cloud_upload:
enable: false
storage_name: ""
upload_path_tmpl: ""
delete_after_upload: false
upload_timing: after_process
timeout_in_us: 60000000
live_rooms: []
cookies: {}
notify:
telegram:
enable: false
withNotification: true
botToken: ""
chatID: ""
email:
enable: false
smtpHost: smtp.qq.com
smtpPort: 465
senderEmail: ""
senderPassword: ""
recipientEmail: ""
ntfy:
enable: false
URL: ""
token: ""
tag: ""
app_data_path: test-output/.appdata-ffmpeg-e2e
read_only_tool_folder: ""
tool_root_folder: ""
task_queue:
max_concurrent: 3
sentry:
enable: false
proxy:
enable: false
url: ""
update:
auto_check: false
check_interval_hours: 6
auto_download: false
include_prerelease: false
+26
View File
@@ -0,0 +1,26 @@
{
"ffmpeg": {
"n8.1-latest": {
"downloadUrl": {
"windows": {
"amd64": ["http://127.0.0.1:8890/ffmpeg.zip"],
"arm64": ["http://127.0.0.1:8890/ffmpeg.zip"]
},
"linux": {
"amd64": ["http://127.0.0.1:8890/ffmpeg.zip"],
"arm64": ["http://127.0.0.1:8890/ffmpeg.zip"]
},
"darwin": {
"amd64": ["http://127.0.0.1:8890/ffmpeg.zip"],
"arm64": ["http://127.0.0.1:8890/ffmpeg.zip"]
}
},
"pathToEntry": {
"linux": "bin/ffmpeg",
"windows": "bin/ffmpeg.exe",
"darwin": "bin/ffmpeg"
},
"printInfoCmd": ["-version"]
}
}
}