diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index c1f563c..d65655d 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3fe54a8..65f4593 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/package.json b/package.json index 35b5e5b..2769f07 100644 --- a/package.json +++ b/package.json @@ -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": "", diff --git a/playwright.ffmpeg.config.ts b/playwright.ffmpeg.config.ts index 68f204e..6f3d60f 100644 --- a/playwright.ffmpeg.config.ts +++ b/playwright.ffmpeg.config.ts @@ -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 编译完成 diff --git a/src/pkg/parser/ffmpeg/ffmpeg.go b/src/pkg/parser/ffmpeg/ffmpeg.go index 762da5f..ae288bf 100644 --- a/src/pkg/parser/ffmpeg/ffmpeg.go +++ b/src/pkg/parser/ffmpeg/ffmpeg.go @@ -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() diff --git a/src/servers/debug_routes_dev.go b/src/servers/debug_routes_dev.go index f1c3471..7821bc5 100644 --- a/src/servers/debug_routes_dev.go +++ b/src/servers/debug_routes_dev.go @@ -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) { diff --git a/src/tools/tools.go b/src/tools/tools.go index b24a5ba..fbf5d65 100644 --- a/src/tools/tools.go +++ b/src/tools/tools.go @@ -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() diff --git a/src/webapp/src/component/ffmpeg-banner/ffmpeg-banner.css b/src/webapp/src/component/ffmpeg-banner/ffmpeg-banner.css index e2ac50b..399c153 100644 --- a/src/webapp/src/component/ffmpeg-banner/ffmpeg-banner.css +++ b/src/webapp/src/component/ffmpeg-banner/ffmpeg-banner.css @@ -1,9 +1,6 @@ +/* 置于普通文档流中,与 update-banner 自然向下堆叠并把页面内容推开, + 避免 fixed 定位导致的横幅互相重叠或遮挡页面顶部内容 */ .ffmpeg-banner { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 1001; border-radius: 0; } diff --git a/src/webapp/src/component/update-banner/update-banner.css b/src/webapp/src/component/update-banner/update-banner.css index cd326e5..fc42bb7 100644 --- a/src/webapp/src/component/update-banner/update-banner.css +++ b/src/webapp/src/component/update-banner/update-banner.css @@ -1,10 +1,5 @@ -/* 更新横幅样式 */ +/* 更新横幅样式:置于普通文档流中,与 ffmpeg-banner 自然堆叠、不遮挡页面内容 */ .update-banner { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 1000; border-radius: 0; } diff --git a/src/webapp/src/utils/sse.ts b/src/webapp/src/utils/sse.ts index 060abdb..794f3fb 100644 --- a/src/webapp/src/utils/sse.ts +++ b/src/webapp/src/utils/sse.ts @@ -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; diff --git a/test/ffmpeg-mock-server/main.go b/test/ffmpeg-mock-server/main.go index df38933..dd8affe 100644 --- a/test/ffmpeg-mock-server/main.go +++ b/test/ffmpeg-mock-server/main.go @@ -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 diff --git a/tests/e2e/ffmpeg-download.spec.ts b/tests/e2e/ffmpeg-download.spec.ts index ec8f775..aa6caf3 100644 --- a/tests/e2e/ffmpeg-download.spec.ts +++ b/tests/e2e/ffmpeg-download.spec.ts @@ -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 不应显示 diff --git a/tests/e2e/ffmpeg-status.spec.ts b/tests/e2e/ffmpeg-status.spec.ts index 9d7430e..b57a23a 100644 --- a/tests/e2e/ffmpeg-status.spec.ts +++ b/tests/e2e/ffmpeg-status.spec.ts @@ -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(); });