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:
1 parent
47f6cf3dd9
commit
13c32ff68d
14 files changed
+805
-21
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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}`))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !dev
|
||||
|
||||
package servers
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
|
||||
func registerDevDebugRoutes(apiRoute *mux.Router) {}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+29
-9
@@ -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 toolsInitError != nil {
|
||||
blog.GetLogger().WithError(toolsInitError).Warn("remotetools init failed, FFmpeg not available")
|
||||
if ctx.Err() != nil {
|
||||
return // 程序退出,不更新状态
|
||||
}
|
||||
// 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') {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user