fix: 处理第二轮 code review 意见并修复 e2e 基础设施问题
后端: - ffmpeg parser 找不到 FFmpeg 且异步初始化仍在 checking/downloading 时 挂起等待终态(可被 ctx 取消或 Stop 中断),等待结束后立即重查并开始 录制,不再进入"找不到 FFmpeg → 5 秒重试"失败循环(Codex P1) - isFFmpegAvailableWithoutRemotetools 与 utils.GetFFmpegPath 语义对齐: 配置了 ffmpeg_path 时以该路径为准不回退 PATH;补 exec.ErrDot → ./ffmpeg 兜底(Copilot / Codex P2) - WaitForToolsInit 在 Init() 重试进行中不再立即返回旧错误,等待本轮 结束后读取最新结果;Init 的 defer 先发布结果再更新状态机(Gemini / Copilot) - 新增 tools.FFmpegStatusChanged() 状态变化通知 channel 前端: - ffmpeg-banner / update-banner 从 fixed 定位改为普通文档流,自然向下 堆叠且把内容推开,不再互相重叠或遮挡页面顶部(Gemini high / Codex P3) - SSEEventType 补充遗漏的 memory_warning 并注册监听 测试基础设施: - mock server 新增 -speed 启动参数,限速在 bgo 启动前即生效(Codex P2) - mock server 下载循环每个 chunk 前重新读取限速,运行时调速立即生效 (Copilot / Codex P2) - 修复 mock zip 结构:模拟真实发布包的单一顶层目录,避免 remotetools 解压剥层后 pathToEntry 对不上导致 install 后找不到工具 - 新增 dev 专用 POST /api/debug/ffmpeg/reinit;下载失败用例先清工具缓存 再重新触发初始化,解决用例间状态污染(Codex P2) - e2e 等待逻辑去掉会被 SSE 长连接卡死的 networkidle(Codex P2) - playwright.ffmpeg.config.ts 的清理副作用限定主进程执行,避免 worker 重新加载配置时删掉下载中的工具目录 - CI 接入 ffmpeg 下载 e2e 并上传报告;package.json 加 test:e2e:ffmpeg (Codex P3) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d5d654daae
commit
9811e0431b
13 files changed
+213
-55
No files matched your search
@@ -83,6 +83,10 @@ jobs:
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npx playwright test
|
||||
|
||||
# FFmpeg 异步下载流程 e2e(独立配置:8085 端口 + mock 下载服务器)
|
||||
- name: Run FFmpeg download E2E tests
|
||||
run: npx playwright test -c playwright.ffmpeg.config.ts
|
||||
|
||||
# 写入报告元数据(供 Trace Viewer 使用)
|
||||
- name: Write report metadata
|
||||
@@ -114,6 +118,24 @@ jobs:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload FFmpeg test report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-ffmpeg
|
||||
path: playwright-report-ffmpeg/
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload FFmpeg test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results-ffmpeg
|
||||
path: test-results-ffmpeg/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
# 输出在线报告查看链接
|
||||
- name: Print report viewer link
|
||||
|
||||
@@ -181,7 +181,9 @@ test/launcher-config-local.json
|
||||
|
||||
# Playwright E2E 测试
|
||||
/test-results/
|
||||
/test-results-ffmpeg/
|
||||
/playwright-report/
|
||||
/playwright-report-ffmpeg/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
"scripts": {
|
||||
"report": "node scripts/report-server.js",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:e2e:ui": "npx playwright test --ui"
|
||||
"test:e2e:ui": "npx playwright test --ui",
|
||||
"test:e2e:ffmpeg": "npx playwright test -c playwright.ffmpeg.config.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
+17
-10
@@ -24,16 +24,21 @@ const toolsConfigPath = path.join(
|
||||
'tests/e2e/fixtures/test-tools-config.json',
|
||||
).replace(/\\/g, '/');
|
||||
|
||||
// 确保 test-output 目录存在并准备配置文件
|
||||
fs.mkdirSync(path.dirname(configRuntime), { recursive: true });
|
||||
fs.copyFileSync(configTemplate, configRuntime);
|
||||
// 以下带副作用的准备工作只能在 Playwright 主进程执行一次。
|
||||
// worker 进程也会重新加载本配置文件(此时 TEST_WORKER_INDEX 已设置),
|
||||
// 若在 worker 中再次执行 rmSync,会把 bgo 正在下载写入的工具目录删掉,导致下载报错。
|
||||
if (!process.env.TEST_WORKER_INDEX) {
|
||||
// 确保 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 (_) {
|
||||
// 目录不存在时忽略
|
||||
// 清空 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 环境变量)
|
||||
@@ -85,8 +90,10 @@ export default defineConfig({
|
||||
|
||||
webServer: [
|
||||
// ffmpeg-mock-server — 提供可控的 FFmpeg zip 下载
|
||||
// 初始限速 1KB/s:必须在 bgo 启动前生效,否则 bgo 的 FFmpegAsyncInit 可能在
|
||||
// 测试用例执行前就完成不限速下载,导致 downloading 状态断言失败
|
||||
{
|
||||
command: 'go run ./test/ffmpeg-mock-server -port 8890',
|
||||
command: 'go run ./test/ffmpeg-mock-server -port 8890 -speed 1024',
|
||||
url: 'http://127.0.0.1:8890/health',
|
||||
reuseExistingServer: false,
|
||||
timeout: 120 * 1000, // 等待 fake-ffmpeg 编译完成
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/bililive-go/bililive-go/src/pkg/parser"
|
||||
bilisentry "github.com/bililive-go/bililive-go/src/pkg/sentry"
|
||||
"github.com/bililive-go/bililive-go/src/pkg/utils"
|
||||
"github.com/bililive-go/bililive-go/src/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,6 +40,7 @@ func (b *builder) Build(cfg map[string]string, logger *livelogger.LiveLogger) (p
|
||||
useFlvProxy := cfg["use_flv_proxy"] == "true"
|
||||
return &Parser{
|
||||
closeOnce: new(sync.Once),
|
||||
stopped: make(chan struct{}),
|
||||
statusReq: make(chan struct{}, 1),
|
||||
statusResp: make(chan map[string]interface{}, 1),
|
||||
timeoutInUs: cfg["timeout_in_us"],
|
||||
@@ -53,6 +55,7 @@ type Parser struct {
|
||||
cmdStdIn io.WriteCloser
|
||||
cmdStdout io.ReadCloser
|
||||
closeOnce *sync.Once
|
||||
stopped chan struct{} // Stop() 被调用时关闭,用于中断 FFmpeg 就绪等待
|
||||
timeoutInUs string
|
||||
audioOnly bool
|
||||
useFlvProxy bool // 是否使用 FLV 代理分段
|
||||
@@ -167,7 +170,15 @@ func (p *Parser) ParseLiveStream(ctx context.Context, streamUrlInfo *live.Stream
|
||||
url := streamUrlInfo.Url
|
||||
ffmpegPath, err := utils.GetFFmpegPathForLive(ctx, live)
|
||||
if err != nil {
|
||||
return err
|
||||
// 找不到 FFmpeg 且后台异步初始化(tools.FFmpegAsyncInit)仍在检测/下载时,
|
||||
// 挂起等待其进入终态后重查一次,避免开播瞬间陷入"找不到 FFmpeg → 5 秒重试"
|
||||
// 的失败循环;下载一完成录制立即开始。等待可被 ctx 取消或 Stop() 中断。
|
||||
if waitErr := p.waitFFmpegAsyncInit(ctx); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
if ffmpegPath, err = utils.GetFFmpegPathForLive(ctx, live); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
headers := streamUrlInfo.HeadersForDownloader
|
||||
ffUserAgent, exists := headers["User-Agent"]
|
||||
@@ -308,6 +319,32 @@ func (p *Parser) ParseLiveStream(ctx context.Context, streamUrlInfo *live.Stream
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitFFmpegAsyncInit 在 FFmpeg 异步初始化处于 checking/downloading 状态时阻塞,
|
||||
// 直到其进入终态(ready/not_found/error)、ctx 取消或 Stop() 被调用。
|
||||
// 终态时返回 nil,由调用方重新查找 FFmpeg 路径并给出与原有逻辑一致的错误。
|
||||
func (p *Parser) waitFFmpegAsyncInit(ctx context.Context) error {
|
||||
logged := false
|
||||
for {
|
||||
// 先取变化通知 channel 再读状态,避免漏掉两步之间的状态变化
|
||||
changed := tools.FFmpegStatusChanged()
|
||||
state := tools.GetFFmpegStatus().State
|
||||
if state != "checking" && state != "downloading" {
|
||||
return nil
|
||||
}
|
||||
if !logged {
|
||||
p.logger.Infof("FFmpeg 尚未就绪(%s),等待后台下载完成后开始录制...", state)
|
||||
logged = true
|
||||
}
|
||||
select {
|
||||
case <-changed:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-p.stopped:
|
||||
return fmt.Errorf("录制在等待 FFmpeg 就绪时被停止")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isFlvStream 判断 URL 是否指向 FLV 流
|
||||
func (p *Parser) isFlvStream(u *url.URL) bool {
|
||||
path := strings.ToLower(u.Path)
|
||||
@@ -336,6 +373,9 @@ func (p *Parser) stopFlvProxy() {
|
||||
|
||||
func (p *Parser) Stop() (err error) {
|
||||
p.closeOnce.Do(func() {
|
||||
// 中断可能正在进行的 FFmpeg 就绪等待
|
||||
close(p.stopped)
|
||||
|
||||
// 先停止 FLV 代理
|
||||
p.stopFlvProxy()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package servers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
@@ -14,6 +15,17 @@ import (
|
||||
func registerDevDebugRoutes(apiRoute *mux.Router) {
|
||||
// POST /api/debug/ffmpeg/force-state — 强制设置 FFmpeg 状态,仅用于 e2e 测试
|
||||
apiRoute.HandleFunc("/debug/ffmpeg/force-state", forceFFmpegStateHandler).Methods("POST")
|
||||
// POST /api/debug/ffmpeg/reinit — 重新触发 FFmpeg 异步初始化流程,仅用于 e2e 测试
|
||||
// (配合删除工具缓存目录,可让下载流程在同一进程内重新走一遍)
|
||||
apiRoute.HandleFunc("/debug/ffmpeg/reinit", reinitFFmpegHandler).Methods("POST")
|
||||
}
|
||||
|
||||
func reinitFFmpegHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 不使用 r.Context():异步初始化的生命周期应跟随进程而非本次请求
|
||||
tools.FFmpegAsyncInit(context.Background())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
func forceFFmpegStateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+44
-9
@@ -56,17 +56,29 @@ func signalToolsReady(err error) {
|
||||
}
|
||||
|
||||
// WaitForToolsInit 等待 Init() 完成,ctx 取消时返回错误。
|
||||
// 若 Init() 曾失败后又被重试并成功,此函数在重试成功后首次被调用时返回 nil。
|
||||
// 若 Init() 曾失败后又被重试,此函数会等待当前这一轮重试结束后再返回其结果,
|
||||
// 避免在重试进行中读到上一轮的旧错误。
|
||||
func WaitForToolsInit(ctx context.Context) error {
|
||||
select {
|
||||
case <-toolsInitDone:
|
||||
toolsInitMu.Lock()
|
||||
err := toolsInitError
|
||||
toolsInitMu.Unlock()
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
// toolsInitDone 关闭后可能有新一轮 Init() 重试正在进行(currentToolStatus 重新
|
||||
// 进入 initializing),此时轮询等待本轮结束,确保读到的是最新一轮的结果。
|
||||
// Init() 的 defer 中先调用 signalToolsReady 更新 toolsInitError、再更新
|
||||
// currentToolStatus,因此观察到状态离开 initializing 时错误值一定已是本轮的。
|
||||
for toolStatusValue(currentToolStatus.Load()) == toolStatusValueInitializing {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
toolsInitMu.Lock()
|
||||
err := toolsInitError
|
||||
toolsInitMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// FFmpegStatus FFmpeg 就绪状态
|
||||
@@ -80,6 +92,10 @@ var (
|
||||
ffmpegStatusVal atomic.Value // stores FFmpegStatus
|
||||
ffmpegCbMu sync.RWMutex
|
||||
ffmpegCallbacks []func(FFmpegStatus)
|
||||
|
||||
// ffmpegStatusChangedCh 在每次状态变化时被关闭并重建,供等待方感知状态变化
|
||||
ffmpegStatusChangedMu sync.Mutex
|
||||
ffmpegStatusChangedCh = make(chan struct{})
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -88,6 +104,10 @@ func init() {
|
||||
|
||||
func setFFmpegStatus(s FFmpegStatus) {
|
||||
ffmpegStatusVal.Store(s)
|
||||
ffmpegStatusChangedMu.Lock()
|
||||
close(ffmpegStatusChangedCh)
|
||||
ffmpegStatusChangedCh = make(chan struct{})
|
||||
ffmpegStatusChangedMu.Unlock()
|
||||
ffmpegCbMu.RLock()
|
||||
cbs := make([]func(FFmpegStatus), len(ffmpegCallbacks))
|
||||
copy(cbs, ffmpegCallbacks)
|
||||
@@ -111,6 +131,14 @@ func IsFFmpegReady() bool {
|
||||
return GetFFmpegStatus().State == "ready"
|
||||
}
|
||||
|
||||
// FFmpegStatusChanged 返回一个在下一次 FFmpeg 状态变化时关闭的 channel。
|
||||
// 配合 GetFFmpegStatus 使用时应先取 channel 再读状态,以避免漏掉两步之间的变化。
|
||||
func FFmpegStatusChanged() <-chan struct{} {
|
||||
ffmpegStatusChangedMu.Lock()
|
||||
defer ffmpegStatusChangedMu.Unlock()
|
||||
return ffmpegStatusChangedCh
|
||||
}
|
||||
|
||||
// OnFFmpegStatusChange 注册 FFmpeg 状态变化回调,在状态改变时被调用
|
||||
func OnFFmpegStatusChange(fn func(FFmpegStatus)) {
|
||||
ffmpegCbMu.Lock()
|
||||
@@ -125,15 +153,20 @@ func ForceFFmpegStatus(s FFmpegStatus) {
|
||||
|
||||
// isFFmpegAvailableWithoutRemotetools 仅检查配置路径和系统 PATH,
|
||||
// 不访问 remotetools(避免在 Init() 完成前触及未配置好的 remotetools 状态)。
|
||||
// 语义与 utils.GetFFmpegPath 保持一致:配置了 ffmpeg_path 时以该路径为准,
|
||||
// 路径无效时不回退到系统 PATH(应走 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 := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
}
|
||||
_, err := exec.LookPath("ffmpeg")
|
||||
if errors.Is(err, exec.ErrDot) {
|
||||
// 允许把 ffmpeg 和主程序放在同一目录(与 utils.GetFFmpegPath 行为一致)
|
||||
_, err = exec.LookPath("./ffmpeg")
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -364,12 +397,14 @@ func Init() (err error) {
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// 先发布本轮结果再更新状态机,保证 WaitForToolsInit 观察到
|
||||
// currentToolStatus 离开 initializing 时 toolsInitError 已是本轮的值
|
||||
signalToolsReady(err)
|
||||
if err != nil {
|
||||
currentToolStatus.Store(int32(toolStatusValueNotInitialized))
|
||||
} else {
|
||||
currentToolStatus.Store(int32(toolStatusValueInitialized))
|
||||
}
|
||||
signalToolsReady(err)
|
||||
}()
|
||||
|
||||
api := tools.Get()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/* 置于普通文档流中,与 update-banner 自然向下堆叠并把页面内容推开,
|
||||
避免 fixed 定位导致的横幅互相重叠或遮挡页面顶部内容 */
|
||||
.ffmpeg-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1001;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
/* 更新横幅样式 */
|
||||
/* 更新横幅样式:置于普通文档流中,与 ffmpeg-banner 自然堆叠、不遮挡页面内容 */
|
||||
.update-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ export type SSEEventType =
|
||||
| 'update_ready'
|
||||
| 'update_error'
|
||||
| 'danmaku'
|
||||
| 'ffmpeg_status';
|
||||
| 'ffmpeg_status'
|
||||
| 'memory_warning';
|
||||
|
||||
// SSE 消息结构
|
||||
export interface SSEMessage {
|
||||
@@ -151,6 +152,11 @@ class SSEManager {
|
||||
this.handleMessage('ffmpeg_status', event.data);
|
||||
});
|
||||
|
||||
// 监听 memory_warning 事件(内存占用警告)
|
||||
this.eventSource.addEventListener('memory_warning', (event: MessageEvent) => {
|
||||
this.handleMessage('memory_warning', event.data);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SSE] Failed to create EventSource:', error);
|
||||
this.isConnecting = false;
|
||||
|
||||
@@ -29,6 +29,9 @@ import (
|
||||
|
||||
var (
|
||||
port = flag.Int("port", 8890, "监听端口")
|
||||
// 初始限速通过启动参数指定,确保在 bgo 启动前就已生效
|
||||
// (若依赖测试用例运行时才调用 /control 设置,bgo 可能已经不限速地完成下载)
|
||||
initialSpeed = flag.Int64("speed", 0, "初始全局限速(字节/秒),0 = 不限速")
|
||||
|
||||
// 全局控制状态
|
||||
globalSpeedBytesPerSec atomic.Int64 // 0 = 不限速
|
||||
@@ -41,6 +44,7 @@ var (
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
globalSpeedBytesPerSec.Store(*initialSpeed)
|
||||
|
||||
go buildZip()
|
||||
|
||||
@@ -94,14 +98,23 @@ func handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
speed := globalSpeedBytesPerSec.Load()
|
||||
fail := globalFail.Load()
|
||||
|
||||
// 查询参数中的 speed 覆盖全局限速(仅对本次请求生效)
|
||||
var querySpeed *int64
|
||||
if s := r.URL.Query().Get("speed"); s != "" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
speed = v
|
||||
querySpeed = &v
|
||||
}
|
||||
}
|
||||
// 每次发送 chunk 前重新求值,让 /control 的运行时调速对进行中的下载立即生效
|
||||
// (例如测试先以 1KB/s 观察 downloading 状态,再调为 0 让下载迅速完成)
|
||||
currentSpeed := func() int64 {
|
||||
if querySpeed != nil {
|
||||
return *querySpeed
|
||||
}
|
||||
return globalSpeedBytesPerSec.Load()
|
||||
}
|
||||
|
||||
fail := globalFail.Load()
|
||||
if r.URL.Query().Get("fail") == "true" {
|
||||
fail = true
|
||||
}
|
||||
@@ -117,15 +130,15 @@ func handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(zipData)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if speed <= 0 {
|
||||
w.Write(zipData)
|
||||
return
|
||||
}
|
||||
|
||||
// 限速发送
|
||||
// 限速发送(speed <= 0 时一次性发送剩余数据)
|
||||
const chunkSize = 4096
|
||||
data := zipData
|
||||
for len(data) > 0 {
|
||||
speed := currentSpeed()
|
||||
if speed <= 0 {
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
n := chunkSize
|
||||
if n > len(data) {
|
||||
n = len(data)
|
||||
@@ -164,8 +177,8 @@ func handleControl(w http.ResponseWriter, r *http.Request) {
|
||||
globalFail.Store(*req.Fail)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(fmt.Sprintf(`{"speed":%d,"fail":%v}`,
|
||||
globalSpeedBytesPerSec.Load(), globalFail.Load())))
|
||||
fmt.Fprintf(w, `{"speed":%d,"fail":%v}`,
|
||||
globalSpeedBytesPerSec.Load(), globalFail.Load())
|
||||
}
|
||||
|
||||
// buildFakeFfmpegZip 编译 test/fake-ffmpeg 并打包成 zip
|
||||
@@ -202,10 +215,12 @@ func buildFakeFfmpegZip() ([]byte, error) {
|
||||
return nil, fmt.Errorf("读取编译产物失败: %w", err)
|
||||
}
|
||||
|
||||
// zip 中的路径和 remotetools config 中的 pathToEntry 保持一致
|
||||
entryPath := "bin/ffmpeg"
|
||||
// 模拟真实 FFmpeg 发布包结构:单一顶层目录 + bin/ 子目录。
|
||||
// remotetools 解压时会把单一顶层目录视为冗余目录剥掉,
|
||||
// 剥掉后剩余的 bin/ffmpeg 需与 config 中的 pathToEntry 一致。
|
||||
entryPath := "ffmpeg-fake-build/bin/ffmpeg"
|
||||
if runtime.GOOS == "windows" {
|
||||
entryPath = "bin/ffmpeg.exe"
|
||||
entryPath = "ffmpeg-fake-build/bin/ffmpeg.exe"
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -12,14 +12,31 @@
|
||||
* - 测试验证 Banner 在 downloading → ready 整个流程中的正确行为
|
||||
*/
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const BGO_BASE = 'http://127.0.0.1:8085';
|
||||
const MOCK_SERVER = 'http://127.0.0.1:8890';
|
||||
|
||||
// 与 tests/e2e/fixtures/ffmpeg-test-config.template.yml 中 app_data_path 对应
|
||||
const FFMPEG_TOOL_CACHE = path.join(
|
||||
__dirname,
|
||||
'../../test-output/.appdata-ffmpeg-e2e/external_tools',
|
||||
);
|
||||
|
||||
function ffmpegBanner(page: Page) {
|
||||
return page.locator('.ffmpeg-banner');
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待 WebUI 主界面渲染完成。
|
||||
* 不能用 waitForLoadState('networkidle'):页面挂载后 SSE 长连接一直保持打开,
|
||||
* networkidle 状态可能永远不会到来。
|
||||
*/
|
||||
async function waitForAppReady(page: Page) {
|
||||
await expect(page.locator('.ant-layout').first()).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** 设置 mock server 的行为 */
|
||||
async function setMockServerControl(
|
||||
request: import('@playwright/test').APIRequestContext,
|
||||
@@ -61,11 +78,12 @@ test.describe('FFmpeg 异步下载流程', () => {
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// 设置 mock server 为慢速(1KB/s),让下载过程持续足够长以供观察
|
||||
await setMockServerControl(request, { speed: 1024, fail: false });
|
||||
// mock server 启动参数已设置初始限速 1KB/s(见 playwright.ffmpeg.config.ts),
|
||||
// 确保 bgo 启动后立即开始的下载不会在测试执行前完成;这里仅确认失败模式关闭
|
||||
await setMockServerControl(request, { fail: false });
|
||||
|
||||
await page.goto(BGO_BASE + '/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 等待出现 downloading 状态(下载启动后 UI 应显示进度横幅)
|
||||
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 30_000 });
|
||||
@@ -87,11 +105,19 @@ test.describe('FFmpeg 异步下载流程', () => {
|
||||
});
|
||||
|
||||
test('下载失败时 Banner 显示 error 状态', async ({ page, request }) => {
|
||||
// 上一个用例已把 fake-ffmpeg 下载进缓存且状态为 ready;
|
||||
// 删除工具缓存并重新触发异步初始化,让下载流程真正重新走一遍
|
||||
fs.rmSync(FFMPEG_TOOL_CACHE, { recursive: true, force: true });
|
||||
|
||||
// 设置 mock server 为失败模式
|
||||
await setMockServerControl(request, { fail: true, speed: 0 });
|
||||
|
||||
// 重新触发 FFmpeg 异步初始化(dev 构建专有 debug 端点)
|
||||
const reinitResp = await request.post(`${BGO_BASE}/api/debug/ffmpeg/reinit`);
|
||||
expect(reinitResp.status()).toBe(200);
|
||||
|
||||
await page.goto(BGO_BASE + '/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 等待 error 状态出现
|
||||
await expect(ffmpegBanner(page)).toBeVisible({ timeout: 30_000 });
|
||||
@@ -111,7 +137,7 @@ test.describe('FFmpeg 异步下载流程', () => {
|
||||
|
||||
// 刷新页面
|
||||
await page.goto(BGO_BASE + '/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await waitForAppReady(page);
|
||||
|
||||
if (status.state === 'ready') {
|
||||
// ready 状态:Banner 不应显示
|
||||
|
||||
@@ -40,8 +40,8 @@ test.describe('FFmpeg 状态 Banner', () => {
|
||||
// 先将状态重置为 ready,确保页面初始加载时 Banner 不可见
|
||||
await forceFFmpegState(request, 'ready');
|
||||
await page.goto('/');
|
||||
// 等待页面基本加载完成
|
||||
await page.waitForLoadState('networkidle');
|
||||
// 等待主界面渲染完成。不能用 networkidle:SSE 长连接会让 networkidle 永远等不到
|
||||
await expect(page.locator('.ant-layout').first()).toBeVisible({ timeout: 15_000 });
|
||||
// Banner 应为隐藏
|
||||
await expect(ffmpegBanner(page)).not.toBeVisible();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user