diff --git a/playwright.config.ts b/playwright.config.ts index 3bd34d4..34b4c35 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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', diff --git a/playwright.ffmpeg.config.ts b/playwright.ffmpeg.config.ts new file mode 100644 index 0000000..68f204e --- /dev/null +++ b/playwright.ffmpeg.config.ts @@ -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', + }, + ], +}); diff --git a/src/servers/debug_routes_dev.go b/src/servers/debug_routes_dev.go new file mode 100644 index 0000000..f1c3471 --- /dev/null +++ b/src/servers/debug_routes_dev.go @@ -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}`)) +} diff --git a/src/servers/debug_routes_stub.go b/src/servers/debug_routes_stub.go new file mode 100644 index 0000000..37f7b45 --- /dev/null +++ b/src/servers/debug_routes_stub.go @@ -0,0 +1,7 @@ +//go:build !dev + +package servers + +import "github.com/gorilla/mux" + +func registerDevDebugRoutes(apiRoute *mux.Router) {} diff --git a/src/servers/server.go b/src/servers/server.go index 4c0a917..20b750b 100644 --- a/src/servers/server.go +++ b/src/servers/server.go @@ -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") diff --git a/src/tools/dev_config.go b/src/tools/dev_config.go index 8efdea7..a87431c 100644 --- a/src/tools/dev_config.go +++ b/src/tools/dev_config.go @@ -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") } diff --git a/src/tools/tools.go b/src/tools/tools.go index 134948d..e50cc21 100644 --- a/src/tools/tools.go +++ b/src/tools/tools.go @@ -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 } diff --git a/src/webapp/src/component/ffmpeg-banner/index.tsx b/src/webapp/src/component/ffmpeg-banner/index.tsx index 27bee86..06de51a 100644 --- a/src/webapp/src/component/ffmpeg-banner/index.tsx +++ b/src/webapp/src/component/ffmpeg-banner/index.tsx @@ -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(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') { diff --git a/test/fake-ffmpeg/main.go b/test/fake-ffmpeg/main.go new file mode 100644 index 0000000..44db5de --- /dev/null +++ b/test/fake-ffmpeg/main.go @@ -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) +} diff --git a/test/ffmpeg-mock-server/main.go b/test/ffmpeg-mock-server/main.go new file mode 100644 index 0000000..df38933 --- /dev/null +++ b/test/ffmpeg-mock-server/main.go @@ -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 +} diff --git a/tests/e2e/ffmpeg-download.spec.ts b/tests/e2e/ffmpeg-download.spec.ts new file mode 100644 index 0000000..ec8f775 --- /dev/null +++ b/tests/e2e/ffmpeg-download.spec.ts @@ -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 }); + } + }); +}); diff --git a/tests/e2e/ffmpeg-status.spec.ts b/tests/e2e/ffmpeg-status.spec.ts new file mode 100644 index 0000000..9d7430e --- /dev/null +++ b/tests/e2e/ffmpeg-status.spec.ts @@ -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 = { 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 }); + }); +}); diff --git a/tests/e2e/fixtures/ffmpeg-test-config.template.yml b/tests/e2e/fixtures/ffmpeg-test-config.template.yml new file mode 100644 index 0000000..7ffd42f --- /dev/null +++ b/tests/e2e/fixtures/ffmpeg-test-config.template.yml @@ -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 diff --git a/tests/e2e/fixtures/test-tools-config.json b/tests/e2e/fixtures/test-tools-config.json new file mode 100644 index 0000000..0a77851 --- /dev/null +++ b/tests/e2e/fixtures/test-tools-config.json @@ -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"] + } + } +}