import { expect, Page, test } from "@playwright/test"; import { mkdir } from "node:fs/promises"; const source = { id: "source-1", kind: "local", name: "私有影片", status: "idle", enabled: true, config: { path: "/media/private", writable: true }, }; const tags = [{ id: "group-1", name: "场景", selection_mode: "multi", sort_order: 0, tag_count: 2, video_count: 2, tags: [ { id: "tag-1", group_id: "group-1", group_name: "场景", selection_mode: "multi", name: "卧室", ai_enabled: true, ai_method: "visual", ai_description: "卧室内景", ai_threshold: .28, match_terms: [], video_count: 2 }, { id: "tag-2", group_id: "group-1", group_name: "场景", selection_mode: "multi", name: "浴室", ai_enabled: false, ai_method: "visual", ai_description: "", ai_threshold: .28, match_terms: [], video_count: 0 }, ], }]; const videos = Array.from({ length: 8 }, (_, index) => ({ id: `video-${index}`, source_id: source.id, display_name: `PRIVATE-${index + 1}.mp4`, title: index ? `私人收藏影片 ${index + 1}` : "示例影片:已整理的私人收藏", source_key: `collection/PRIVATE-${index + 1}.mp4`, source_name: source.name, source_kind: "local", source_driver: "local", size_bytes: 2_000_000_000 + index * 1000, duration_ms: 5_400_000, width: index % 2 ? 1920 : 3840, height: index % 2 ? 1080 : 2160, status: "indexed", available: true, catalog_code: `PRIVATE-${String(index + 1).padStart(3, "0")}`, studio: "本地收藏", series: "示例系列", collection_id:"collection-1",collection_name:"示例合集",collection_position:index, description: "这是用于验证布局的本地示例资料。", actors: ["示例演员"], tags: index < 2 ? ["卧室"] : [], tag_items: index < 2 ? [{ id: "tag-1", name: "卧室", group_id: "group-1", group_name: "场景", selection_mode: "multi" }] : [], liked: index === 0, favorited: index === 1, progress_ms: index < 2 ? 900_000 + index * 300_000 : 0, completed: false, last_played_at: index < 2 ? "2026-07-27T12:00:00Z" : null, created_at: "2026-07-27T10:00:00Z", updated_at: "2026-07-27T10:00:00Z", playback_url: `/api/v1/videos/video-${index}/stream`, download_url: `/api/v1/videos/video-${index}/download`, })); const collections=[{id:"collection-1",name:"示例合集",description:"按推荐顺序连续观看",cover_video_id:"video-0",video_count:8,duration_ms:43_200_000,thumbnail_url:null,created_at:"2026-07-27T10:00:00Z",updated_at:"2026-07-27T10:00:00Z"}]; async function mockApi(page: Page, accessMode: "direct"|"gateway"="direct", gatewayAuthRecovery=false) { let preferences = { autoplay: true, mask_covers: false, theme: "system" as "system"|"light"|"dark", home_video_columns: 2 as 1|2, upload_paths: {} }; let speechConfig = { language_policy: "zh_priority" as "zh_priority"|"auto"|"zh", quality_profile: "accuracy" as "speed"|"balanced"|"accuracy", index_revision: 2 }; let webdavConfig = { enabled: false, source_id: source.id as string|null, relative_path: "", url: "http://127.0.0.1:8765/webdav/", username: "imagefind", direct_access: true, direct_port: 8765, gateway_supported: false as const }; let collectionTags = [tags[0].tags[0]]; let markers = [{ id: "marker-1", video_id: "video-0", position_ms: 600_000, title: "精彩片段", created_at: "2026-07-27T10:00:00Z", updated_at: "2026-07-27T10:00:00Z" }]; let backupExports:Array>=[]; const authProbe={gatewayLoginRequests:0,gatewayMediaTokenRequests:0,gatewaySessionHeaders:[] as string[]}; await page.route("**/api/v1/**", async route => { const url = new URL(route.request().url()); const apiIndex = url.pathname.indexOf("/api/v1"); const path = url.pathname.slice(apiIndex + "/api/v1".length); let body: unknown = {}; if (path === "/status") body = { configured: true, version: "0.5.45", access_mode: accessMode }; else if (path === "/auth/me") { const gatewayHeader=route.request().headers()["x-imagefind-gateway-session"]||""; authProbe.gatewaySessionHeaders.push(gatewayHeader); if(gatewayAuthRecovery&&gatewayHeader!=="gateway-session-token"){ await route.fulfill({status:401,contentType:"application/json",body:JSON.stringify({detail:"飞牛管理员会话需要重新建立"})}); return; } body = { csrf: gatewayAuthRecovery?"gateway-csrf":"test-csrf" }; } else if(path==="/auth/gateway/media-token"){ authProbe.gatewayMediaTokenRequests+=1; body={gateway_media_token:"gateway-media-token",gateway_media_expires_at:"2026-07-30T00:00:00Z"}; } else if(path==="/auth/gateway"){ authProbe.gatewayLoginRequests+=1; body={csrf_token:"gateway-csrf",gateway_session_token:"gateway-session-token",gateway_media_token:"gateway-media-token",kind:"gateway",nas_user_id:"nas-admin",nas_username:"Administrator"}; } else if (path === "/sources") body = [source]; else if (path === "/home") body = { video_count:videos.length,updated_at:"2026-07-27T10:00:00Z", recent:{id:"recent",title:"最近新增",total:videos.length,items:videos}, collections:{total:collections.length,items:collections}, categories:[{id:"tag:tag-1",tag_id:"tag-1",title:"卧室",group_name:"场景",total:2,items:videos.slice(0,2)}], people:[{id:"actor-1",name:"示例演员",video_count:8,thumbnail_url:null}], unorganized:null, }; else if (path === "/files") body = []; else if (path === "/videos") body = videos; else if (path === "/search/coverage") body = {available:true,model_version:"audio-v1",total:8,indexed:5,searchable:4,empty:1,queued:2,running:1,failed:0,pending:0,percent:50,current_model_percent:62.5}; else if (/^\/videos\/[^/]+\/transcript$/.test(path)) body = {status:"ready",model_version:"audio-v1",indexed_version:"audio-v1",detected_language:"zh",quality_score:.94,quality_state:"filtered",rejected_segments:1,quality_flags:["short_hallucination"],items:[{id:"speech-1",start_ms:180000,end_ms:184000,raw_text:"这是一段可以搜索的中文音频转写",language:"zh",quality_score:.96},{id:"speech-2",start_ms:360000,end_ms:365000,raw_text:"点击时间片段可以直接跳转播放",language:"zh",quality_score:.92}],page:1,page_size:30,total:2,pages:1,job:null}; else if (/^\/videos\/[^/]+\/transcript\/reindex$/.test(path)) body = {job_id:"audio-reindex-1",status:"queued"}; else if (/^\/videos\/[^/]+\/markers$/.test(path)) { if (route.request().method() === "POST") { const request = route.request().postDataJSON() as {position_ms:number;title?:string}; const videoId = path.split("/")[2]; const marker = { id: `marker-${markers.length + 1}`, video_id: videoId, position_ms: request.position_ms, title: request.title || "", created_at: "2026-07-27T10:00:00Z", updated_at: "2026-07-27T10:00:00Z" }; markers = [...markers, marker]; body = marker; } else body = markers.filter(marker => marker.video_id === path.split("/")[2]); } else if (/^\/videos\/[^/]+\/markers\/[^/]+$/.test(path)) { const markerId = path.split("/")[4]; if (route.request().method() === "PATCH") { const request = route.request().postDataJSON() as {title?:string}; markers = markers.map(marker => marker.id === markerId ? { ...marker, title: request.title || "", updated_at: "2026-07-27T11:00:00Z" } : marker); body = markers.find(marker => marker.id === markerId); } else if (route.request().method() === "DELETE") { markers = markers.filter(marker => marker.id !== markerId); body = { deleted: true }; } } else if (path.startsWith("/videos/") && route.request().method() === "DELETE") { body = { video_id: path.split("/")[2], trash_id: "trash-video", cancelled_jobs: 0 }; } else if (path === "/uploads") body = []; else if (path === "/downloads") body = []; else if (path === "/downloads/runtime") body = { available: true, running: false }; else if (path === "/jobs") body = url.searchParams.has("page")?{items:[],page:1,page_size:10,total:0,pages:1,retryable_failed_count:0}:[]; else if (path === "/people") body = []; else if (path === "/actors") body = [{ id: "actor-1", name: "示例演员", video_count: 8, aliases: [] }]; else if (path === "/tag-groups") body = tags; else if (path === "/preferences") { if (route.request().method() === "PATCH") preferences = { ...preferences, ...(route.request().postDataJSON() as Partial) }; body = preferences; } else if (path === "/profile") body = { nickname: "管理员", avatar: { text: "管", color: "#3974dc", accent: "#6f9df1" }, role: "administrator", created_at: "2026-01-01T00:00:00Z", counts: { favorites: 1, likes: 1, history: 2, continue_watching: 2 } }; else if(path==="/collections") body=route.request().method()==="POST"?{...collections[0],id:"collection-new",name:(route.request().postDataJSON() as {name:string}).name}:collections; else if(path==="/collections/collection-1/tags") { if(route.request().method()==="PUT") { const ids=(route.request().postDataJSON() as {tag_ids:string[]}).tag_ids; collectionTags=tags.flatMap(group=>group.tags).filter(tag=>ids.includes(tag.id)); body={applied:videos.length}; } else body=collectionTags; } else if(path==="/collections/collection-1") body={...collections[0],videos}; else if(path.startsWith("/collections/")) body={updated:1}; else if (path === "/series") body = [{ name: "示例系列", video_count: 8, duration_ms: 43_200_000 }]; else if (path === "/storage/usage") body = { disk: { total_bytes: 4 * 1024 ** 4, used_bytes: 2 * 1024 ** 4, available_bytes: 2 * 1024 ** 4 }, app_bytes: 20 * 1024 ** 3, categories: { database: { bytes: 1024 ** 3, available: true }, ai_index: { bytes: 8 * 1024 ** 3, available: true, mode: "qdrant" }, models: { bytes: 5 * 1024 ** 3, available: true }, thumbnails: { bytes: 2 * 1024 ** 3, available: true }, preview_cache: { bytes: 3 * 1024 ** 3, available: true }, upload_staging: { bytes: 0, available: true }, other: { bytes: 1024 ** 3, available: true } } }; else if (path === "/system/resources") body = { profile:"balanced",ai_gpu_mode:"balanced",ai_gpu_profile:{mode:"balanced",selected_mode:"balanced",degraded:false,hint:"THROUGHPUT",streams:2,batch_size:2},manual_pause:false,running_jobs:1,paused_jobs:0,cpu_percent:12.5,memory_total_bytes:8*1024**3,memory_available_bytes:5*1024**3,disk_available_bytes:2*1024**4,database:{engine:"postgresql",journal_mode:"server",writer_queue_depth:0,writer_active:false,last_wait_ms:0.4,max_wait_ms:12.5,lock_retry_count:0,slow_transaction_count:0},lanes:{ai:{limit:1,running:1,queued:2,paused:0},transfer:{limit:1,running:0,queued:1,paused:0},download:{limit:1,running:0,queued:0,paused:0},scan:{limit:1,running:0,queued:0,paused:0}},policy:{cpu_pause_percent:70,cpu_resume_percent:55,memory_reserve_mb:768,memory_reserve_percent:20,disk_reserve_gb:5},io:{supported:true,state:"normal",scope:"data_volume",some_avg10:61.2,some_avg60:48.8,full_avg10:32.2,full_avg60:21.1,volume_supported:true,volume_sampled:true,volume_device:"253:1",volume_mount_point:"/vol1",volume_in_flight:1,volume_utilization_percent:21.4,effective_limit_bytes:12*1024**2},remote_cache:{limit_bytes:10*1024**3,used_bytes:2*1024**3,active_bytes:1024**3,evictable_bytes:1024**3,entries:2,downloading:1},rclone:{managed:1,orphaned:0,draining:1,verifying:1,unresolved:0,reclaimed:2,last_checked_at:"2026-08-05T08:00:00Z",next_check_seconds:60,reasons:{observing_idle:1},cache_bytes:128*1024**2} }; else if (path === "/system/resources/rclone/reconcile") body = {matched:1,reaped:0,verifying:1,unresolved:0,status:{managed:1,orphaned:0,draining:1,verifying:1,unresolved:0,reclaimed:2,last_checked_at:"2026-08-05T08:01:00Z",next_check_seconds:60,reasons:{observing_idle:1},cache_bytes:128*1024**2}}; else if (path === "/speech/config") { if(route.request().method()==="PATCH")speechConfig={...speechConfig,...route.request().postDataJSON() as Partial}; body=speechConfig; } else if (path === "/speech/reconcile") body={scanned:8,flagged:2,current:6,queued:2}; else if (path === "/activity") body = { items: [], unread: 0 }; else if (path === "/tag-suggestions") body = []; else if (path === "/models") body = { visual_ready: false, ocr_ready: false, faces_ready: false, audio_ready: false, pending_videos: 0, hf_endpoint: "https://huggingface.co", pip: { index_url: "https://pypi.tuna.tsinghua.edu.cn/simple", pytorch_index_url: "https://download.pytorch.org/whl/cpu" }, accelerator: { selected: "首次推理时自动选择" }, proxy: { enabled: false, url: "", username: "", has_password: false }, component_sizes: { audio: 0 }, component_versions: {},runtime_dependencies:{},runtime_environment:{state:"missing",size_bytes:0,components:[]},component_health:{}, sources: { visual_image: "https://huggingface.co/sentence-transformers/clip-ViT-B-32", visual_text: "https://huggingface.co/sentence-transformers/clip-ViT-B-32-multilingual-v1", ocr: "https://github.com/RapidAI/RapidOCR/tree/main/python/rapidocr_onnxruntime/rapidocr_onnxruntime/models", audio: "https://huggingface.co/openai/whisper-small", faces: { detector_xml: "https://models.example/face-detection.xml", detector_bin: "https://models.example/face-detection.bin", reidentification_xml: "https://models.example/face-reidentification.xml", reidentification_bin: "https://models.example/face-reidentification.bin" }, }, }; else if (path === "/tokens") body = route.request().method()==="POST"?{id:"token-webdav",name:"WebDAV 专用 Token 1",token:"dav-secret-once",created_at:"2026-07-27T10:00:00Z"}:[]; else if (path === "/webdav/config") { if(route.request().method()==="PATCH") webdavConfig={...webdavConfig,...route.request().postDataJSON() as Partial}; body=webdavConfig; } else if (path === "/backups/status") body = { empty: true, can_restore: true, blockers: {}, business_counts: { sources: 0, videos: 0, people: 0, actors: 0, tags: 0 }, supported_format_versions: [1], max_upload_bytes: 2 * 1024 ** 3, max_extract_bytes: 4 * 1024 ** 3, }; else if (path === "/backups") { if(route.request().method()==="POST"){ const request=route.request().postDataJSON() as {scope:"keys"|"full"}; const queued={id:"backup-1",job_id:"backup-job-1",scope:request.scope,status:"queued",created_at:"2026-07-28T10:00:00Z"}; backupExports=[queued]; await route.fulfill({status:202,contentType:"application/json",body:JSON.stringify(queued)}); return; } if(backupExports.length&&backupExports[0].status==="queued")backupExports=[{...backupExports[0],status:"completed",filename:"imagefind-backup-20260728-full.ifbackup",size_bytes:16,finished_at:"2026-07-28T10:00:03Z"}]; body=backupExports; } else if (/^\/backups\/[^/]+\/download$/.test(path)) { await route.fulfill({ status: 200, contentType: "application/vnd.imagefind.backup", headers: { "Content-Disposition": 'attachment; filename="imagefind-backup-20260728-full.ifbackup"' }, body: Buffer.from("encrypted-backup"), }); return; } else if (path === "/backups/restore") body = { scope: "full", created_at: "2026-07-28T10:00:00Z", source_app_version: "0.3.3", counts: { sources: 2, videos: 8, tag_groups: 1, tags: 2, actors: 1, video_metadata: 8, video_state: 2 }, scan_jobs: [{ source_id: "source-1", job_id: "scan-1" }, { source_id: "source-2", job_id: "scan-2" }], warnings: ["本地来源“离线盘”路径暂不可用,将在路径恢复后重新扫描"], }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); }); return authProbe; } async function visualSnapshot(page: Page, name: string, fullPage=true) { await expect(page.locator(".mobile-more-menu.closing")).toHaveCount(0); await page.evaluate(()=>document.fonts.ready); await page.evaluate(async()=>{ const finite=document.getAnimations().filter(animation=>Number.isFinite(animation.effect?.getComputedTiming().endTime??Number.POSITIVE_INFINITY)); await Promise.all(finite.map(animation=>animation.finished.catch(()=>undefined))); }); const cjkFont=await page.evaluate(()=>{ const canvas=document.createElement("canvas");const context=canvas.getContext("2d")!; context.font='32px monospace';const fallback=context.measureText("Wi中文排版验证").width; context.font='32px "Noto Sans CJK SC", monospace';const noto=context.measureText("Wi中文排版验证").width; return Math.abs(noto-fallback)>.1; }); expect(cjkFont,"视觉测试需要安装 fonts-noto-cjk").toBe(true); const metrics = await page.evaluate(() => { const rect = (selector: string) => { const box = document.querySelector(selector)?.getBoundingClientRect(); return box ? { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) } : null; }; return { viewport: { width: innerWidth, height: innerHeight }, document: { width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }, topbar: rect(".topbar"), sidebar: rect(".sidebar"), content: rect(".content"), page: rect(".page"), videoGrid: rect(".video-grid,.home-rail-track"), tagGroups: rect(".tag-groups"), mobileMore: rect(".mobile-more-menu"), }; }); expect(metrics.document.width).toBeLessThanOrEqual(metrics.viewport.width + 1); expect(JSON.stringify(metrics, null, 2)).toMatchSnapshot(name.replace(".png", ".json")); await expect(page).toHaveScreenshot(name, { fullPage }); } async function openSettings(page: Page, width: number) { if (width <= 720) { await page.getByRole("button", { name: "更多" }).click(); await page.locator(".mobile-more-menu").getByRole("button", { name: "设置" }).click(); } else { await page.locator(".nav-item-8").click({ force: true }); } await expect(page.getByRole("heading", { name: "设置" })).toBeVisible(); } type AuditPage = "home"|"search"|"library"|"series"|"uploads"|"tags"|"actors"|"profile"|"settings"|"player"; const auditHeadings:Partial>={home:"最近新增",search:"搜索你的媒体库",library:"媒体资料库",series:"合集",uploads:"传输中心",tags:"分类与标签",actors:"演员与人物",profile:"管理员",settings:"设置"}; const auditMenuNames:Partial>={library:"资料库",series:"合集",uploads:"上传中心",tags:"分类与标签",actors:"演员与人物",settings:"设置"}; async function openAuditPage(page:Page,destination:AuditPage,width:number){ await page.goto("/"); const mobile=width<=720; if(destination==="search")await page.getByRole("button",{name:"搜索",exact:true}).first().click(); else if(destination==="profile"){ if(mobile)await page.locator(".mobile-profile-button").click(); else{await page.locator(".avatar").click();await page.locator(".account-popover").getByRole("button",{name:"个人中心"}).click()} }else if(destination==="player")await page.locator(".video-card").first().click(); else if(destination!=="home"){ if(mobile){ await page.getByRole("button",{name:"更多"}).click(); await page.locator(".mobile-more-menu").getByRole("button",{name:new RegExp(auditMenuNames[destination]||destination)}).first().click(); }else{ const selector:Record,string>={library:".nav-item-3",series:".nav-item-4",uploads:".sidebar nav > .upload-status",tags:".nav-item-6",actors:".nav-item-7",settings:".nav-item-8"}; await page.locator(selector[destination as keyof typeof selector]).click(); } } if(destination==="player")await expect(page.locator(".player-stage")).toBeVisible(); else await expect(page.getByRole("heading",{name:auditHeadings[destination]!,exact:destination!=="profile"}).first()).toBeVisible(); } test("desktop home and tag management visual contract", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await mockApi(page); await page.goto("/"); await expect(page.getByRole("heading", { name: "最近新增" })).toBeVisible(); await expect(page.getByText("你的私有媒体空间")).toHaveCount(0); await expect(page.locator(".home-content-rail")).toHaveCount(4); await expect(page.locator(".home-rail-track").first().locator(".video-card")).toHaveCount(8); await expect(page.getByText("继续观看", { exact: true })).toHaveCount(0); const recentRail=page.locator(".home-rail-track").first(); await expect(page.locator(".home-feed").getByRole("button",{name:"单列显示"})).toHaveCount(0); await expect(page.locator(".home-feed").getByRole("button",{name:"双列显示"})).toHaveCount(0); const recentSection=page.locator(".home-content-rail").filter({has:page.getByRole("heading",{name:"最近新增"})}); await recentSection.getByRole("button",{name:/查看全部/}).click(); const focusedGrid=page.locator(".home-focused-browser .home-video-grid"); const compactCardWidth=(await focusedGrid.locator(".video-card").first().boundingBox())!.width; const layoutRequest=page.waitForRequest(request=>request.url().endsWith("/api/v1/preferences")&&request.method()==="PATCH"); await page.getByRole("button",{name:"单列显示"}).click(); expect((await layoutRequest).postDataJSON()).toMatchObject({home_video_columns:1}); await expect(focusedGrid).toHaveClass(/columns-1/); const wideCardWidth=(await focusedGrid.locator(".video-card").first().boundingBox())!.width; expect(wideCardWidth).toBeGreaterThan(compactCardWidth*1.5); await page.getByRole("button",{name:"双列显示"}).click(); await expect(focusedGrid).toHaveClass(/columns-2/); await page.goBack(); await expect(recentRail).toBeVisible(); await visualSnapshot(page, "desktop-home.png"); await page.getByRole("button", { name: "分类与标签" }).click({ force: true }); await expect(page.getByRole("heading", { name: "分类与标签" })).toBeVisible(); await expect(page.getByText("画面识别 ≥ 0.28")).toBeVisible(); await visualSnapshot(page, "desktop-tags.png"); }); test("search focus and mobile filters remain contained",async({page})=>{ await page.setViewportSize({width:1440,height:900}); await mockApi(page); await page.goto("/"); const globalInput=page.locator(".global-search>input:not([type=file])"); await globalInput.focus(); await expect(globalInput).toHaveCSS("outline-style","none"); await page.setViewportSize({width:390,height:844}); await page.getByRole("button",{name:"搜索",exact:true}).first().click(); const mobileInput=page.getByLabel("搜索内容"); await mobileInput.focus(); await expect(mobileInput).toHaveCSS("outline-style","none"); const searchHeaderLayout=await page.locator(".mobile-search-header").evaluate(header=>{const outer=header.getBoundingClientRect();const children=[...header.children].filter(element=>(element as HTMLElement).offsetParent!==null).map(element=>{const box=element.getBoundingClientRect();return {top:box.top,bottom:box.bottom}});return {height:outer.height,contains:children.every(box=>box.top>=outer.top-1&&box.bottom<=outer.bottom+1)}}); expect(searchHeaderLayout.height).toBeGreaterThanOrEqual(64); expect(searchHeaderLayout.contains).toBe(true); for(const selector of [".recognition-filter",".filter-row"]){ const filterMetrics=await page.locator(selector).evaluate(element=>({right:element.getBoundingClientRect().right,viewport:innerWidth,scrollWidth:element.scrollWidth,clientWidth:element.clientWidth})); expect(filterMetrics.right).toBeLessThanOrEqual(filterMetrics.viewport+1); expect(filterMetrics.scrollWidth).toBeLessThanOrEqual(filterMetrics.clientWidth+1); } const selectStyles=await page.locator(".filter-row select").evaluateAll(elements=>elements.map(element=>({appearance:getComputedStyle(element).appearance,background:getComputedStyle(element).backgroundImage,height:element.getBoundingClientRect().height}))); expect(selectStyles.every(style=>style.appearance==="none"&&style.background!=="none"&&style.height>=44)).toBe(true); await expect(page.locator("html")).toHaveJSProperty("scrollWidth",390); }); test("home shelves open bounded browse views and preserve browser navigation",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); await page.route("**/api/v1/home**",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({ video_count:videos.length,updated_at:"2026-07-27T10:00:00Z", recent:{id:"recent",title:"最近新增",total:videos.length,items:videos.slice(0,4)}, collections:{total:1,items:collections},categories:[],people:[],unorganized:null, })})); let requestedOffset=""; await page.route("**/api/v1/videos?*",async route=>{ const url=new URL(route.request().url()); if(url.searchParams.get("offset")!=="4")return route.fallback(); requestedOffset=url.searchParams.get("offset")||""; await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(videos.slice(4))}); }); await page.goto("/"); const recent=page.locator(".home-content-rail").filter({has:page.getByRole("heading",{name:"最近新增"})}); await expect(recent.locator(".video-card")).toHaveCount(4); await recent.getByRole("button",{name:/查看全部/}).click(); await expect(page.locator(".home-focused-browser")).toBeVisible(); await expect(page.locator(".home-focused-browser .video-card")).toHaveCount(4); await page.getByRole("button",{name:"加载更多"}).click(); await expect(page.locator(".home-focused-browser .video-card")).toHaveCount(8); expect(requestedOffset).toBe("4"); await page.goBack(); await expect(recent).toBeVisible(); await expect(page.locator(".home-focused-browser")).toHaveCount(0); await page.locator(".home-collection-card").click(); await expect(page.getByRole("heading",{name:"示例合集"})).toBeVisible(); await expect(page.locator(".collection-preview-directory")).toBeVisible(); await expect(page.locator(".collection-row-actions")).toHaveCount(0); await page.goBack(); const collectionsRail=page.locator(".home-content-rail").filter({has:page.getByRole("heading",{name:"合集"})}); await collectionsRail.getByRole("button",{name:/查看全部/}).click(); await expect(page.locator(".collection-browser-grid .collection-overview-card")).toHaveCount(1); await expect(page.locator(".collection-preview-video,.collection-video-row")).toHaveCount(0); }); test("video covers summarize classification tags without crowding mobile cards",async({page})=>{ await page.setViewportSize({width:320,height:780}); await mockApi(page); const tagged={...videos[0],tag_items:[ tags[0].tags[0],tags[0].tags[1], {id:"tag-extra",group_id:"group-extra",group_name:"内容类型",selection_mode:"multi" as const,name:"需要截断的超长分类名称",ai_enabled:false,ai_method:"visual",ai_description:"",ai_threshold:.28,match_terms:[],video_count:1}, ]}; await page.route("**/api/v1/home**",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({ video_count:videos.length,updated_at:"2026-07-27T10:00:00Z", recent:{id:"recent",title:"最近新增",total:videos.length,items:[tagged,videos[1],...videos.slice(2)]}, collections:{total:0,items:[]},categories:[],people:[],unorganized:null, })})); await page.goto("/"); const first=page.locator(".home-rail-track .video-card").first(); const badges=first.locator(".video-cover-tags"); await expect(badges.locator(":scope > span")).toHaveCount(2); await expect(badges.locator(":scope > b")).toHaveText("+1"); await expect(first).toHaveAttribute("aria-label",/分类 卧室、浴室、需要截断的超长分类名称/); await expect(page.locator(".home-rail-track .video-card").nth(2).locator(".video-cover-tags")).toHaveCount(0); const contained=await first.locator(".video-thumb").evaluate(thumb=>{const cover=thumb.getBoundingClientRect();const tags=thumb.querySelector(".video-cover-tags")!.getBoundingClientRect();const duration=thumb.querySelector(".duration")!.getBoundingClientRect();return tags.left>=cover.left&&tags.right<=cover.right+1&&tags.top>=cover.top&&tags.bottomdocument.documentElement.scrollWidth<=innerWidth)).toBe(true); }); test("mobile navigation keeps every page reachable", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockApi(page); await page.goto("/"); await expect(page.getByRole("heading", { name: "最近新增" })).toBeVisible(); await expect(page.getByText("你的私有媒体空间")).toHaveCount(0); await expect(page.getByText("继续观看", { exact: true })).toHaveCount(0); await expect(page.locator(".home-rail-track").first().locator(".video-card")).toHaveCount(8); await expect(page.locator(".topbar")).toBeVisible(); await expect(page.locator(".mobile-profile-button")).toBeVisible(); await visualSnapshot(page, "mobile-home.png"); await page.getByRole("button", { name: "更多" }).click({ force: true }); await expect(page.getByRole("button", { name: "设置" })).toBeVisible(); await expect(page.getByRole("button", { name: "分类与标签" })).toBeVisible(); await expect(page.locator(".mobile-more-menu").getByRole("button", { name: "个人中心" })).toHaveCount(0); await visualSnapshot(page, "mobile-more.png",false); await page.locator(".mobile-more-menu").getByRole("button", { name: "资料库",exact:true }).click(); await expect(page.getByRole("heading", { name: "媒体资料库" })).toBeVisible(); await expect(page.locator(".sidebar")).toBeHidden(); await visualSnapshot(page,"mobile-library.png"); await page.goBack(); await expect(page.getByRole("heading", { name: "最近新增" })).toBeVisible(); await page.getByRole("button", { name: "更多" }).click(); await page.locator(".mobile-more-menu").getByRole("button", { name: "分类与标签" }).click(); await expect(page.getByRole("heading", { name: "分类与标签" })).toBeVisible(); await expect(page.locator(".topbar")).toBeHidden(); await expect(page.locator(".sidebar")).toBeHidden(); const tagActions = page.locator(".tag-list article").first().locator(":scope > button"); await expect(tagActions).toHaveCount(3); for (let index = 0; index < 3; index += 1) { await expect(tagActions.nth(index)).toBeVisible(); const box = await tagActions.nth(index).boundingBox(); expect(box).not.toBeNull(); expect(box!.x + box!.width).toBeLessThanOrEqual(391); } }); test("mobile app hierarchy uses immersive secondary screens and system history", async ({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); await page.goto("/"); const destinations=[ ["合集","合集","mobile-series.png"], ["上传中心","传输中心","mobile-uploads.png"], ["分类与标签","分类与标签","mobile-tags.png"], ["演员与人物","演员与人物","mobile-actors.png"], ] as const; for(const [menuName,heading,snapshot] of destinations){ await page.getByRole("button",{name:"更多"}).click(); await page.locator(".mobile-more-list").getByRole("button",{name:new RegExp(menuName)}).first().click(); await expect(page.getByRole("heading",{name:heading}).first()).toBeVisible(); await expect(page.locator(".topbar")).toBeHidden(); await expect(page.locator(".sidebar")).toBeHidden(); await visualSnapshot(page,snapshot); await page.goBack(); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); await expect(page.locator(".sidebar")).toBeVisible(); } await page.getByRole("button",{name:"搜索",exact:true}).click(); await expect(page.getByRole("form",{name:"移动端搜索"})).toBeVisible(); await expect(page.locator(".topbar")).toBeHidden(); await visualSnapshot(page,"mobile-search.png"); await page.goBack(); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); await page.evaluate(()=>{const content=document.querySelector(".content")!;content.style.scrollBehavior="auto";content.scrollTo(0,280)}); await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollTop)).toBeGreaterThan(150); await page.locator(".video-card").nth(4).click({force:true}); await expect(page.locator(".player-stage")).toBeVisible(); await expect(page.locator(".topbar")).toBeHidden(); await expect(page.locator(".sidebar")).toBeHidden(); await expect(page.locator(".player-back")).toBeVisible(); await visualSnapshot(page,"mobile-player.png"); await page.goBack(); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollTop)).toBeGreaterThan(150); }); test("mobile home syncs its column layout and keeps actions in video details", async ({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); await page.goto("/"); const grid=page.locator(".home-rail-track").first(); await expect(grid).toHaveClass(/columns-2/); const firstCard=page.locator(".video-card").first(); await expect(firstCard.locator(".library-avatar,.source-badge,.card-menu-wrap")).toHaveCount(0); await expect(firstCard.locator(".duration")).toHaveText("1:30:00"); await expect(firstCard).toContainText("PRIVATE-001"); await expect(firstCard).toContainText("示例演员"); await expect(firstCard).not.toContainText(source.name); const twoColumnBox=await firstCard.boundingBox(); expect(twoColumnBox).not.toBeNull(); expect(twoColumnBox!.width).toBeLessThan(190); await expect(page.locator(".home-feed").getByRole("button",{name:/单列|双列/})).toHaveCount(0); const recentSection=page.locator(".home-content-rail").filter({has:page.getByRole("heading",{name:"最近新增"})}); await recentSection.getByRole("button",{name:/查看全部/}).click(); const focusedGrid=page.locator(".home-focused-browser .home-video-grid"); await expect(focusedGrid).toHaveClass(/columns-2/); const requestPromise=page.waitForRequest(request=>request.url().endsWith("/api/v1/preferences")&&request.method()==="PATCH"); await page.getByRole("button",{name:"单列显示"}).click(); const request=await requestPromise; expect(request.postDataJSON()).toMatchObject({home_video_columns:1}); await expect(focusedGrid).toHaveClass(/columns-1/); const oneColumnBox=await focusedGrid.locator(".video-card").first().boundingBox(); expect(oneColumnBox).not.toBeNull(); expect(oneColumnBox!.width).toBeGreaterThan(300); await page.goBack(); await page.reload(); await expect(page.locator(".home-rail-track").first()).toHaveClass(/columns-1/); await page.locator(".video-card").first().click(); await expect(page.locator(".player-stage")).toBeVisible(); for(const name of ["喜欢","收藏","下载","编辑资料"]){ await expect(page.getByRole(name==="下载"?"link":"button",{name:new RegExp(name)})).toBeVisible(); } }); for (const width of [320, 390, 768, 1024, 1440]) { test(`settings and icons stay usable at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: width <= 390 ? 780 : 900 }); await mockApi(page); await page.goto("/"); await openSettings(page, width); await expect(page.getByText("全部模型下载地址")).toBeVisible(); if (width <= 720) {await expect(page.locator(".topbar")).toBeHidden();await expect(page.locator(".sidebar")).toBeHidden()} else { await expect(page.locator(".topbar")).toBeVisible(); await expect(page.getByRole("button",{name:"首页",exact:true})).toBeVisible(); await expect(page.getByRole("button",{name:"设置",exact:true})).toBeVisible(); } const layout = await page.evaluate(() => { const centers = [...document.querySelectorAll(".icon-button")] .filter(button => { const style = getComputedStyle(button); return style.display !== "none" && button.getBoundingClientRect().width > 0; }) .map(button => { const icon = button.querySelector("svg"); if (!icon) return 0; const outer = button.getBoundingClientRect(); const inner = icon.getBoundingClientRect(); return Math.max( Math.abs(outer.left + outer.width / 2 - (inner.left + inner.width / 2)), Math.abs(outer.top + outer.height / 2 - (inner.top + inner.height / 2)), ); }); return { overflow: document.documentElement.scrollWidth - innerWidth, maxIconCenterError: Math.max(0, ...centers), }; }); expect(layout.overflow).toBeLessThanOrEqual(1); expect(layout.maxIconCenterError).toBeLessThanOrEqual(1); await visualSnapshot(page, `settings-${width}.png`); }); } test("mobile more closes on browser back and keeps each item centered", async ({ page }) => { await page.setViewportSize({ width: 320, height: 780 }); await mockApi(page); await page.goto("/"); await page.getByRole("button", { name: "更多" }).click(); await expect(page.locator(".mobile-more-backdrop")).toBeVisible(); await page.goBack(); await expect(page.locator(".mobile-more-menu")).toBeHidden(); await page.getByRole("button", { name: "更多" }).click(); const settingsItem=page.locator(".mobile-more-menu").getByRole("button", { name: "设置" }); const centers=await settingsItem.evaluate(item=>{ const outer=item.getBoundingClientRect(); const icon=item.querySelector(".mobile-more-icon")||item.querySelector("svg")!; const label=[...item.children].find(child=>child!==icon&&child.tagName==="SPAN") as HTMLElement; const primary=[icon,label]; const boxes=primary.map(child=>child.getBoundingClientRect()); return { vertical:Math.abs(outer.top+outer.height/2-(Math.min(...boxes.map(box=>box.top))+Math.max(...boxes.map(box=>box.bottom)))/2), horizontal:Math.max(...boxes.map(box=>Math.abs(outer.left+outer.width/2-(box.left+box.width/2)))), }; }); expect(centers.vertical).toBeLessThanOrEqual(1); expect(centers.horizontal).toBeLessThanOrEqual(1); await settingsItem.click(); await expect(page.getByRole("heading",{name:"设置"})).toBeVisible(); await expect(page.locator(".sidebar")).toBeHidden(); await page.goBack(); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); }); test("upload picker appends and removes multiple files", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockApi(page); await page.goto("/"); await page.locator(".sidebar nav > .upload-status").click(); const picker = page.locator(".drop-zone input[type=file]"); await picker.setInputFiles([ { name: "first.mp4", mimeType: "video/mp4", buffer: Buffer.from("first") }, { name: "second.mkv", mimeType: "video/x-matroska", buffer: Buffer.from("second") }, ]); await expect(page.locator(".selected-files>div")).toHaveCount(2); await picker.setInputFiles({ name: "third.mp4", mimeType: "video/mp4", buffer: Buffer.from("third") }); await expect(page.locator(".selected-files>div")).toHaveCount(3); await page.getByRole("button", { name: "移除 second.mkv" }).click(); await expect(page.locator(".selected-files>div")).toHaveCount(2); await expect(page.getByText("first.mp4")).toBeVisible(); await expect(page.getByText("third.mp4")).toBeVisible(); }); test("upload batch metadata survives closing the dialog while chunks continue", async ({ page }) => { await page.setViewportSize({ width: 1024, height: 800 }); await mockApi(page); const submitted:Array>=[]; const chunkIndexes:number[]=[]; let firstChunkSeen!:()=>void; let releaseFirstChunk!:()=>void; const firstChunk=new Promise(resolve=>{firstChunkSeen=resolve}); const chunkGate=new Promise(resolve=>{releaseFirstChunk=resolve}); await page.route("**/api/v1/uploads**",async route=>{ const request=route.request(); const path=new URL(request.url()).pathname; if(request.method()==="GET")return route.fulfill({status:200,contentType:"application/json",body:"[]"}); if(request.method()==="POST"&&path.endsWith("/uploads")){ const body=request.postDataJSON() as Record;submitted.push(body); return route.fulfill({status:201,contentType:"application/json",body:JSON.stringify({id:"upload-background",...body,status:"receiving",progress:0,bytes_received:0,chunk_size:5,total_chunks:2,received_chunks:[]})}); } const chunk=path.match(/\/chunks\/(\d+)$/); if(request.method()==="PUT"&&chunk){ const index=Number(chunk[1]);chunkIndexes.push(index); if(index===0){firstChunkSeen();await chunkGate} return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({received:index})}); } if(request.method()==="POST"&&path.endsWith("/complete"))return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({status:"queued"})}); return route.fallback(); }); await page.goto("/"); await page.locator(".top-upload .upload-status").click(); await page.getByLabel("加入合集").selectOption("collection-1"); await page.locator(".upload-tag-groups").getByRole("button",{name:"浴室"}).click(); await page.locator(".drop-zone input[type=file]").setInputFiles({name:"background.mp4",mimeType:"video/mp4",buffer:Buffer.from("0123456789")}); await page.getByRole("button",{name:"开始上传"}).click(); await firstChunk; await expect(page.getByRole("button",{name:"后台继续",exact:true})).toBeVisible(); await page.getByRole("button",{name:"后台继续",exact:true}).click(); await expect(page.getByRole("heading",{name:"上传到私有媒体库"})).toBeHidden(); releaseFirstChunk(); await expect.poll(()=>chunkIndexes).toEqual([0,1]); expect(submitted).toHaveLength(1); expect(submitted[0]).toMatchObject({collection_id:"collection-1",tag_ids:["tag-2"]}); }); test("collection default categories can be saved and propagated",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); const updates:string[][]=[]; await page.route("**/api/v1/collections/collection-1/tags",async route=>{ if(route.request().method()==="PUT"){ const ids=(route.request().postDataJSON() as {tag_ids:string[]}).tag_ids;updates.push(ids); return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({applied:8})}); } return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify([tags[0].tags[0]])}); }); await page.goto("/"); await page.locator(".nav-item-4").click(); await page.getByRole("button",{name:"管理合集"}).click(); await page.locator(".collection-list").getByRole("button",{name:/示例合集/}).click(); await expect(page.getByRole("heading",{name:"示例合集"})).toBeVisible(); await page.getByRole("button",{name:"默认分类"}).click(); const rules=page.locator(".collection-tag-rules"); await expect(rules.getByRole("button",{name:"卧室"})).toHaveAttribute("aria-pressed","true"); await rules.getByRole("button",{name:"浴室"}).click(); await rules.getByRole("button",{name:"保存并应用"}).click(); await expect.poll(()=>updates).toEqual([["tag-1","tag-2"]]); await expect(page.locator(".toast-host")).toContainText("默认分类已应用到 8 部影片"); }); test("nested collection directories stay usable on mobile",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); const sectionVideo={...videos[0],collection_item_id:"item-video",collection_parent_id:"group-section",collection_path:["第一章","第一节"]}; const tree=[{id:"group-chapter",collection_id:"collection-1",parent_id:null,kind:"group",name:"第一章",position:0,path:["第一章"],video_count:1,duration_ms:sectionVideo.duration_ms,children:[{id:"group-section",collection_id:"collection-1",parent_id:"group-chapter",kind:"group",name:"第一节",position:0,path:["第一章","第一节"],video_count:1,duration_ms:sectionVideo.duration_ms,children:[{id:"item-video",collection_id:"collection-1",parent_id:"group-section",kind:"video",video_id:sectionVideo.id,position:0,path:["第一章","第一节"],duration_ms:sectionVideo.duration_ms,video:sectionVideo}]}]}]; let moved:{parent_id:string|null;position:number}|null=null; await page.route("**/api/v1/collections/collection-1",async route=>{ if(route.request().method()!=="GET")return route.fallback(); await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({...collections[0],video_count:1,videos:[sectionVideo],items:tree})}); }); await page.route("**/api/v1/collections/collection-1/items/item-video/move",async route=>{ moved=route.request().postDataJSON() as typeof moved; await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({id:"item-video",...moved})}); }); await page.goto("/"); await page.getByRole("button",{name:"更多"}).click(); await page.locator(".mobile-more-menu").getByText("合集",{exact:true}).click(); await page.getByRole("button",{name:"管理合集"}).click(); await page.locator(".collection-list").getByRole("button",{name:/示例合集/}).click(); await expect(page.locator(".collection-group-row").filter({hasText:"第一章"})).toBeVisible(); const videoRow=page.locator(".collection-video-row").filter({hasText:videos[0].title}); await expect(videoRow).toContainText("第一章 / 第一节"); await videoRow.getByRole("button",{name:"移动",exact:true}).click(); const dialog=page.getByRole("dialog"); await expect(dialog).toContainText("合集根级(未分组)"); await dialog.getByRole("button",{name:/示例合集/}).click(); await expect.poll(()=>moved).toEqual({parent_id:null,position:1}); expect(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth)).toBe(true); }); test("player time markers stay inside the player and support complete editing",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await page.addInitScript(()=>{ const mediaSources=new WeakMap(); Object.defineProperty(HTMLMediaElement.prototype,"src",{configurable:true,get(){return mediaSources.get(this)||""},set(value:string){mediaSources.set(this,value)}}); Object.defineProperty(HTMLMediaElement.prototype,"load",{configurable:true,value:function(){}}); Object.defineProperty(HTMLMediaElement.prototype,"play",{configurable:true,value:function(this:HTMLMediaElement){Object.defineProperty(this,"paused",{configurable:true,writable:true,value:false});this.dispatchEvent(new Event("playing"));return Promise.resolve()}}); }); await mockApi(page); await page.goto("/"); await page.locator(".video-card").first().click(); const video=page.locator("video"); await video.evaluate(element=>{Object.defineProperties(element,{duration:{configurable:true,value:5400},currentTime:{configurable:true,writable:true,value:120},paused:{configurable:true,writable:true,value:true}});element.dispatchEvent(new Event("loadedmetadata"));element.dispatchEvent(new Event("timeupdate"));element.dispatchEvent(new Event("canplay"))}); await expect(page.locator(".video-marker-panel")).toHaveCount(0); await expect(page.locator(".player-actions").getByRole("button",{name:/添加时间点/})).toHaveCount(0); await page.getByRole("button",{name:"视频时间点"}).click(); const markerPanel=page.locator(".player-marker-drawer"); await expect(markerPanel.getByText("精彩片段")).toBeVisible(); await markerPanel.getByRole("button",{name:/精彩片段/}).click(); expect(await video.evaluate(element=>element.currentTime)).toBe(600); await video.evaluate(element=>{element.currentTime=123;element.dispatchEvent(new Event("timeupdate"))}); await markerPanel.getByRole("button",{name:"标记当前位置"}).click(); await expect(markerPanel.locator("article")).toHaveCount(2); const added=markerPanel.locator("article").last(); await added.getByRole("button",{name:"编辑时间点"}).click(); const rename=page.getByRole("alertdialog"); await rename.getByLabel("请输入").fill("片尾彩蛋"); await rename.getByRole("button",{name:"确定"}).click(); await expect(added.getByText("片尾彩蛋")).toBeVisible(); await added.getByRole("button",{name:"删除时间点"}).click(); await page.getByRole("alertdialog").getByRole("button",{name:"继续"}).click(); await expect(markerPanel.locator("article")).toHaveCount(1); const transcript=page.locator(".transcript-panel"); await expect(transcript).toContainText("2 个高可信时间片段"); await transcript.getByRole("button",{name:/音频转写/}).click(); await transcript.getByRole("button",{name:/这是一段可以搜索的中文音频转写/}).click(); expect(await video.evaluate(element=>element.currentTime)).toBe(180); await expect(transcript.getByRole("button",{name:"重新识别"})).toBeVisible(); await transcript.getByRole("button",{name:"重新识别"}).click(); const languageDialog=page.getByRole("dialog",{name:"重新识别音频"}); await expect(languageDialog).toBeVisible(); await languageDialog.getByRole("radio",{name:/中文/}).click(); const reindexRequest=page.waitForRequest(request=>request.url().endsWith(`/videos/${videos[0].id}/transcript/reindex`)&&request.method()==="POST"); await languageDialog.getByRole("button",{name:"开始识别"}).click(); expect((await reindexRequest).postDataJSON()).toEqual({language:"zh"}); await expect(languageDialog).toHaveCount(0); }); test("WebDAV settings expose the write target and save without storing credentials",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); const updates:Array>=[]; await page.route("**/api/v1/webdav/config",async route=>{ if(route.request().method()==="PATCH")updates.push(route.request().postDataJSON() as Record); const request=updates.at(-1)||{}; return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({enabled:false,source_id:source.id,relative_path:"",url:"http://127.0.0.1:8765/webdav/",username:"imagefind",direct_access:true,direct_port:8765,gateway_supported:false,...request})}); }); await page.goto("/"); await openSettings(page,1024); await page.getByRole("button",{name:"账户与 API"}).click(); const panel=page.locator(".webdav-server-panel"); await expect(panel).toContainText("http://127.0.0.1:8765/webdav/"); await expect(panel).toContainText("imagefind"); await expect(panel).toContainText("第一层目录作为合集"); await panel.getByRole("switch",{name:"启用 WebDAV 入口"}).click(); await panel.getByLabel("目标基础目录(可选)").fill("手机上传"); await panel.getByRole("button",{name:"保存 WebDAV 设置"}).click(); await expect.poll(()=>updates).toEqual([{enabled:true,source_id:source.id,relative_path:"手机上传"}]); expect(updates[0]).not.toHaveProperty("password"); await panel.getByRole("button",{name:"创建专用 WebDAV Token"}).click(); await expect(panel).toContainText("dav-secret-once"); await expect(panel).toContainText("仅显示一次"); }); test("WebDAV address stays on the native port when the page uses the gateway",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await page.addInitScript(()=>{window.__IMAGEFIND_BASE__="/app/imagefind/"}); await mockApi(page,"gateway"); await page.route("**/api/v1/webdav/config",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({enabled:true,source_id:source.id,relative_path:"",url:"http://127.0.0.1:8765/webdav/",username:"imagefind",direct_access:true,direct_port:8765,gateway_supported:false})})); await page.goto("/app/imagefind/"); await openSettings(page,1024); await page.getByRole("button",{name:"账户与 API"}).click(); const panel=page.locator(".webdav-server-panel"); await expect(panel).toContainText("http://127.0.0.1:8765/webdav/"); await expect(panel).toContainText("5666 只用于网页统一网关"); await expect(panel).not.toContainText("/app/imagefind/webdav/"); }); test("mobile home pull gesture refreshes all visible dashboard resources",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); const refreshPaths:string[]=[]; page.on("request",request=>{if(request.method()==="GET"){const path=new URL(request.url()).pathname;for(const suffix of ["/videos","/collections","/profile","/uploads"]){if(path.endsWith(`/api/v1${suffix}`))refreshPaths.push(suffix)}}}); await page.goto("/"); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); refreshPaths.length=0; const content=page.locator(".content"); await content.dispatchEvent("touchstart",{touches:[{identifier:1,clientX:180,clientY:80}]}); await content.dispatchEvent("touchmove",{touches:[{identifier:1,clientX:180,clientY:240}]}); await expect(page.locator(".pull-refresh")).toHaveClass(/ready/); await expect(page.locator(".pull-refresh")).toContainText("松开刷新"); await content.dispatchEvent("touchend",{changedTouches:[{identifier:1,clientX:180,clientY:240}]}); await expect(page.locator(".toast-host")).toContainText("首页已刷新"); await expect.poll(()=>[...new Set(refreshPaths)].sort()).toEqual(["/collections","/profile","/uploads","/videos"]); expect(await page.evaluate(()=>document.documentElement.scrollWidth-innerWidth)).toBeLessThanOrEqual(1); }); test("backup download and restore stay usable on desktop and mobile", async ({ page }) => { await page.setViewportSize({ width: 1024, height: 900 }); await mockApi(page); await page.goto("/"); await openSettings(page, 1024); await page.getByRole("button", { name: "备份恢复" }).click(); await expect(page.getByRole("heading", { name: "系统备份与恢复" })).toBeVisible(); await page.getByLabel("独立备份密码").fill("portable backup password"); await page.getByLabel("确认备份密码").fill("portable backup password"); await page.getByRole("button", { name: "后台生成备份" }).click(); await expect(page.getByText("等待后台任务开始")).toBeVisible(); await expect(page.getByText("imagefind-backup-20260728-full.ifbackup")).toBeVisible(); const downloadPromise = page.waitForEvent("download"); await page.locator(".backup-history").getByRole("button",{name:"下载"}).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe("imagefind-backup-20260728-full.ifbackup"); await expect(page.getByLabel("独立备份密码")).toHaveValue(""); await page.setViewportSize({ width: 390, height: 844 }); await page.locator('.backup-file-picker input[type="file"]').setInputFiles({ name: "portable.ifbackup", mimeType: "application/vnd.imagefind.backup", buffer: Buffer.from("encrypted-backup"), }); await page.getByLabel("备份密码", { exact: true }).fill("portable backup password"); await page.getByLabel("我确认恢复到空系统").check(); await page.getByRole("button", { name: "确认恢复" }).click(); await expect(page.getByText("恢复完成 · 完整备份")).toBeVisible(); await expect(page.getByText("已创建 2 个来源扫描任务")).toBeVisible(); await expect(page.getByText(/路径暂不可用/)).toBeVisible(); await expect(page.getByLabel("备份密码", { exact: true })).toHaveValue(""); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth); expect(overflow).toBeLessThanOrEqual(1); }); test("backup restore shows a short authentication error without retaining the password", async ({ page }) => { await page.setViewportSize({ width: 320, height: 780 }); await mockApi(page); await page.route("**/api/v1/backups/restore", route => route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ detail: "备份文件无效、已被篡改或密码错误" }), })); await page.goto("/"); await openSettings(page, 320); await page.getByRole("button", { name: "备份恢复" }).click(); await page.locator('.backup-file-picker input[type="file"]').setInputFiles({ name: "broken.ifbackup", mimeType: "application/vnd.imagefind.backup", buffer: Buffer.from("broken"), }); await page.getByLabel("备份密码", { exact: true }).fill("incorrect backup password"); await page.getByLabel("我确认恢复到空系统").check(); await page.getByRole("button", { name: "确认恢复" }).click(); await expect(page.getByText("备份文件无效、已被篡改或密码错误")).toBeVisible(); await expect(page.getByLabel("备份密码", { exact: true })).toHaveValue(""); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth); expect(overflow).toBeLessThanOrEqual(1); }); test("mobile bottom bar has five destinations and more supports every close method", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockApi(page); await page.goto("/"); const visibleBottom = page.locator(".sidebar nav > button:visible"); await expect(visibleBottom).toHaveCount(5); await expect(visibleBottom.nth(0)).toContainText("首页"); await expect(visibleBottom.nth(1)).toContainText("搜索"); await expect(visibleBottom.nth(2)).toContainText("上传中心"); await expect(visibleBottom.nth(3)).toContainText("个人中心"); await expect(visibleBottom.nth(4)).toContainText("更多"); const uploadCenter=await visibleBottom.nth(2).locator("svg").boundingBox(); expect(uploadCenter).not.toBeNull(); expect(Math.abs(uploadCenter!.x+uploadCenter!.width/2-195)).toBeLessThanOrEqual(1); await visibleBottom.nth(3).click(); await expect(page.getByRole("heading", { name: "管理员" })).toBeVisible(); await expect(visibleBottom.nth(3)).toHaveAttribute("aria-current","page"); await visibleBottom.nth(0).click(); await page.locator(".content").evaluate(element=>element.scrollTo(0,260)); const scrollBefore=await page.locator(".content").evaluate(element=>element.scrollTop); const pageBefore=await page.locator(".page").evaluate(element=>{const box=element.getBoundingClientRect();return{x:box.x,width:box.width}}); await visibleBottom.nth(4).click(); await expect(page.locator("body")).toHaveClass(/sheet-open/); await expect(page.locator(".content")).toHaveClass(/sheet-locked/); const overlay=await page.evaluate(()=>{ const backdrop=document.querySelector(".mobile-more-backdrop")!.getBoundingClientRect(); const sheet=document.querySelector(".mobile-more-menu")!; const bottom=document.querySelector(".sidebar")!; const grid=document.querySelector(".mobile-more-grid")!; return {x:backdrop.x,y:backdrop.y,width:backdrop.width,height:backdrop.height,sheetZ:Number(getComputedStyle(sheet).zIndex),bottomZ:Number(getComputedStyle(bottom).zIndex),bodyPosition:getComputedStyle(document.body).position,gridColumns:getComputedStyle(grid).gridTemplateColumns.split(" ").length,sheetAnimation:getComputedStyle(sheet).animationName}; }); expect(overlay).toMatchObject({x:0,y:0,width:390,height:844,bodyPosition:"static",gridColumns:3,sheetAnimation:"imagefind-sheet-in"}); expect(overlay.sheetZ).toBeGreaterThan(overlay.bottomZ); const itemLayout=await page.locator(".mobile-more-grid>.mobile-more-item").evaluateAll(items=>items.map(item=>{ const outer=item.getBoundingClientRect();const style=getComputedStyle(item); const icon=item.querySelector(".mobile-more-icon")!; const label=[...item.children].find(child=>child!==icon&&child.tagName==="SPAN") as HTMLElement; const boxes=[icon,label].map(child=>child.getBoundingClientRect()); return { background:style.backgroundColor,display:style.display,align:style.alignItems,justify:style.justifyContent,iconBackground:getComputedStyle(icon).backgroundColor, horizontal:Math.max(...boxes.map(box=>Math.abs(outer.left+outer.width/2-(box.left+box.width/2)))), vertical:Math.abs(outer.top+outer.height/2-(Math.min(...boxes.map(box=>box.top))+Math.max(...boxes.map(box=>box.bottom)))/2), }; })); expect(itemLayout.length).toBeGreaterThan(0); expect(itemLayout.every(item=>item.background==="rgba(0, 0, 0, 0)"&&item.iconBackground==="rgba(0, 0, 0, 0)"&&item.display==="grid"&&item.align==="center"&&item.justify==="center"&&item.horizontal<=1&&item.vertical<=1),JSON.stringify(itemLayout)).toBe(true); await expect(page.locator("body > .mobile-more-backdrop")).toHaveCount(1); for (const name of ["资料库", "合集", "上传中心", "分类与标签", "演员与人物", "设置"]) { await expect(page.locator(".mobile-more-menu").getByRole("button", { name: new RegExp(name) }).first()).toBeVisible(); } await expect(page.locator(".mobile-more-menu").getByRole("button", { name: "个人中心" })).toHaveCount(0); await page.locator(".sheet-close").click(); await expect(page.locator(".mobile-more-menu")).toBeHidden(); await expect(page.locator(".content")).not.toHaveClass(/sheet-locked/); await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollTop)).toBe(scrollBefore); await expect.poll(()=>page.locator(".page").evaluate(element=>{const box=element.getBoundingClientRect();return{x:box.x,width:box.width}})).toEqual(pageBefore); await expect(visibleBottom.nth(4)).toBeFocused(); await visibleBottom.nth(4).click(); await page.keyboard.press("Escape"); await expect(page.locator(".mobile-more-menu")).toBeHidden(); await visibleBottom.nth(4).click(); const backdrop=page.getByLabel("关闭更多菜单");const backdropBox=await backdrop.boundingBox(); expect(backdropBox).not.toBeNull(); await page.mouse.move(8,8);await page.mouse.down(); const pressedBackdrop=await backdrop.evaluate(element=>({transform:getComputedStyle(element).transform,background:getComputedStyle(element).backgroundColor,box:(()=>{const value=element.getBoundingClientRect();return{x:value.x,y:value.y,width:value.width,height:value.height}})()})); expect(pressedBackdrop).toEqual({transform:"none",background:"rgba(7, 16, 28, 0.44)",box:{x:0,y:0,width:390,height:844}}); await page.mouse.up(); await expect(page.locator(".mobile-more-menu")).toBeHidden(); await visibleBottom.nth(4).click(); await page.locator(".mobile-more-menu").dispatchEvent("touchstart", { touches: [{ identifier: 1, clientX: 20, clientY: 300 }] }); await page.locator(".mobile-more-menu").dispatchEvent("touchend", { changedTouches: [{ identifier: 1, clientX: 20, clientY: 390 }] }); await expect(page.locator(".mobile-more-menu")).toBeHidden(); }); test("gateway SSO recovers without a cookie and persists its scoped session header",async({page})=>{ await page.setViewportSize({width:390,height:844}); await page.addInitScript(()=>{window.__IMAGEFIND_BASE__="/app/imagefind/"}); const probe=await mockApi(page,"gateway",true); const nativeMediaRequests:string[]=[]; page.on("request",request=>{if(request.url().includes("/videos/video-0/stream"))nativeMediaRequests.push(request.url())}); const gatewayVideos=videos.map((video,index)=>({...video,thumbnail_url:`/api/v1/frames/frame-${index}/thumbnail`})); await page.route("**/api/v1/videos?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(gatewayVideos)})); await page.route("**/api/v1/home?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({video_count:gatewayVideos.length,updated_at:"2026-07-27T10:00:00Z",recent:{id:"recent",title:"最近新增",total:gatewayVideos.length,items:gatewayVideos},collections:{total:0,items:[]},categories:[],people:[],unorganized:null})})); await page.goto("/app/imagefind/"); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); await expect.poll(()=>page.evaluate(()=>sessionStorage.getItem("imagefind:gateway-session"))).toBe("gateway-session-token"); await expect.poll(()=>page.evaluate(()=>sessionStorage.getItem("imagefind:gateway-media-token"))).toBe("gateway-media-token"); const thumbnailUrl=await page.locator(".video-card .thumb-image").first().getAttribute("src"); expect(thumbnailUrl).toContain("/app/imagefind/api/v1/frames/frame-0/thumbnail?media_token=gateway-media-token"); expect(thumbnailUrl).not.toContain("gateway-session-token"); expect(probe.gatewayLoginRequests).toBeGreaterThanOrEqual(1); expect(probe.gatewaySessionHeaders.some(header=>header==="")).toBe(true); expect(probe.gatewaySessionHeaders.some(header=>header==="gateway-session-token")).toBe(true); const loginRequestsAfterBoot=probe.gatewayLoginRequests; await page.reload(); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); expect(probe.gatewayLoginRequests).toBe(loginRequestsAfterBoot); expect(probe.gatewayMediaTokenRequests).toBeGreaterThanOrEqual(1); expect(probe.gatewaySessionHeaders.at(-1)).toBe("gateway-session-token"); await page.getByRole("button",{name:"更多"}).click(); const gatewayMenuStyles=await page.locator(".mobile-more-grid>.mobile-more-item").evaluateAll(items=>items.map(item=>({button:getComputedStyle(item).backgroundColor,icon:getComputedStyle(item.querySelector(".mobile-more-icon")!).backgroundColor}))); expect(gatewayMenuStyles.every(style=>style.button==="rgba(0, 0, 0, 0)"&&style.icon==="rgba(0, 0, 0, 0)")).toBe(true); await page.locator(".sheet-close").click(); await page.locator(".video-card").first().click(); await expect.poll(()=>nativeMediaRequests.some(url=>url.includes("media_token=gateway-media-token"))).toBe(true); const nativeMedia=await page.locator("video").evaluate(video=>({poster:video.getAttribute("poster")})); const downloadHref=await page.locator("a.action").filter({hasText:"下载"}).getAttribute("href"); expect(nativeMedia.poster).toContain("media_token=gateway-media-token"); expect(downloadHref).toContain("media_token=gateway-media-token"); expect(`${nativeMediaRequests.join("")}${nativeMedia.poster}${downloadHref}`).not.toContain("gateway-session-token"); await expect(page.getByText("飞牛管理员会话需要重新建立")).toHaveCount(0); }); test("concurrent gateway 401 responses share one session renewal",async({page})=>{ await page.setViewportSize({width:390,height:844}); await page.addInitScript(()=>{ window.__IMAGEFIND_BASE__="/app/imagefind/"; sessionStorage.setItem("imagefind:gateway-session","expired-session"); sessionStorage.setItem("imagefind:gateway-media-token","expired-media"); }); const probe=await mockApi(page,"gateway"); const protectedHeaders:string[]=[]; await page.route(/\/api\/v1\/(?:profile|preferences|collections)(?:\?|$)/,route=>{ const request=route.request();const header=request.headers()["x-imagefind-gateway-session"]||""; protectedHeaders.push(header); if(header!=="gateway-session-token")return route.fulfill({status:401,contentType:"application/json",body:JSON.stringify({detail:"飞牛管理员会话需要重新建立"})}); const path=new URL(request.url()).pathname; const body=path.endsWith("/profile") ? {nickname:"管理员",avatar:{text:"管",color:"#3974dc",accent:"#6f9df1"},role:"administrator",created_at:"2026-01-01T00:00:00Z",counts:{favorites:1,likes:1,history:2,continue_watching:2}} : path.endsWith("/preferences") ? {autoplay:true,mask_covers:false,theme:"system",home_video_columns:2,upload_paths:{}} : collections; return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(body)}); }); await page.goto("/app/imagefind/"); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); await expect.poll(()=>probe.gatewayLoginRequests).toBe(1); expect(protectedHeaders.filter(header=>header==="expired-session").length).toBeGreaterThanOrEqual(2); await expect.poll(()=>protectedHeaders.filter(header=>header==="gateway-session-token").length).toBeGreaterThanOrEqual(2); await expect.poll(()=>page.evaluate(()=>sessionStorage.getItem("imagefind:gateway-media-token"))).toBe("gateway-media-token"); }); test("mobile controls meet touch sizing and video cards support keyboard activation",async({page})=>{ await page.setViewportSize({width:320,height:780}); await mockApi(page); await page.goto("/"); await expect(page.locator(".home-rail-track").first().getByRole("button",{name:/播放 示例影片/})).toHaveCount(1); const undersized=await page.locator("button:visible,[role=button]:visible,a.action:visible,a.icon-button:visible").evaluateAll(elements=>elements.map(element=>{ const box=element.getBoundingClientRect();return {label:element.getAttribute("aria-label")||element.textContent?.trim()||element.tagName,width:box.width,height:box.height}; }).filter(item=>item.width<43.5||item.height<43.5)); expect(undersized).toEqual([]); const first=page.locator(".video-card").first(); await first.focus(); await page.keyboard.press("Enter"); await expect(page.locator(".player-stage")).toBeVisible(); }); test("source, search and recommendation cards support keyboard activation",async({page})=>{ await page.setViewportSize({width:1440,height:900}); await mockApi(page); await page.route("**/api/v1/search",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({items:[{ frame_id:"keyboard-result",video_id:"video-0",display_name:videos[0].display_name,source_name:source.name,source_kind:"local",timestamp_ms:65000,segment_start_ms:65000,segment_end_ms:68000,thumbnail_url:"",playback_url:videos[0].playback_url,score:.91,match_sources:["metadata"], }]})})); await page.goto("/"); await page.locator(".nav-item-3").click(); const sourceCard=page.getByRole("button",{name:`打开媒体来源 ${source.name}`}); await sourceCard.focus(); await page.keyboard.press("Enter"); await expect(sourceCard).toHaveClass(/active/); await page.locator(".nav-item-2").click(); await page.locator(".global-search input:not([type=file])").fill("示例"); await page.locator(".global-search input:not([type=file])").press("Enter"); const result=page.getByRole("button",{name:/播放 示例影片:已整理的私人收藏/}); await expect(result).toHaveAttribute("tabindex","0"); await result.focus(); await page.keyboard.press("Space"); await expect(page.locator(".player-title h1")).toHaveText("示例影片:已整理的私人收藏"); const recommendation=page.locator(".player-recommendations-list").getByRole("button").first(); await expect(recommendation).toHaveAttribute("tabindex","0"); const nextTitle=(await recommendation.getAttribute("aria-label"))!.replace(/^播放 /,""); await recommendation.focus(); await page.keyboard.press("Enter"); await expect(page.locator(".player-title h1")).toHaveText(nextTitle); }); test("gateway subpath prefixes API and media URLs without adding fnOS close actions", async ({page})=>{ await page.setViewportSize({width:390,height:844}); await page.addInitScript(()=>{window.__IMAGEFIND_BASE__="/app/imagefind/"}); await mockApi(page,"gateway"); const apiPaths:string[]=[]; page.on("request",request=>{const path=new URL(request.url()).pathname;if(path.includes("/api/v1/"))apiPaths.push(path)}); await page.goto("/app/imagefind/"); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); await expect(page.locator(".app-shell")).toHaveClass(/access-gateway/); const gatewayLayout=await page.evaluate(()=>{ const shell=document.querySelector(".app-shell")!;const content=document.querySelector(".content")!;const topbar=document.querySelector(".topbar")!; return {access:document.documentElement.dataset.imagefindAccess,safeTop:getComputedStyle(shell).getPropertyValue("--imagefind-safe-top").trim(),shellOverflow:getComputedStyle(shell).overflow,contentOverflow:getComputedStyle(content).overflowY,contentScrollTop:content.scrollTop,topbarTop:Math.round(topbar.getBoundingClientRect().top),viewportHeight:innerHeight,documentHeight:document.documentElement.scrollHeight,visualHeight:getComputedStyle(document.documentElement).getPropertyValue("--imagefind-visual-height").trim()}; }); expect(gatewayLayout.access).toBe("gateway"); expect(gatewayLayout.safeTop).toBe("0px"); expect(gatewayLayout.shellOverflow).toBe("hidden"); expect(gatewayLayout.contentOverflow).toBe("auto"); expect(gatewayLayout.contentScrollTop).toBe(0); expect(gatewayLayout.topbarTop).toBe(0); expect(gatewayLayout.documentHeight).toBeLessThanOrEqual(gatewayLayout.viewportHeight+1); expect(Number.parseFloat(gatewayLayout.visualHeight)).toBeGreaterThan(800); await page.locator(".content").evaluate(element=>element.scrollTo({top:280,behavior:"instant"})); await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollTop)).toBeGreaterThan(150); await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0); const gatewayScroll=await page.locator(".content").evaluate(element=>element.scrollTop); await page.getByRole("button",{name:"更多"}).click(); await expect(page.locator(".content")).toHaveClass(/sheet-locked/); await page.locator(".sheet-close").click(); await expect(page.locator(".mobile-more-menu")).toHaveCount(0); await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollTop)).toBe(gatewayScroll); await page.setViewportSize({width:390,height:700}); await expect.poll(()=>page.evaluate(()=>Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--imagefind-visual-height")))).toBeGreaterThan(690); await page.setViewportSize({width:390,height:844}); expect(apiPaths.length).toBeGreaterThan(3); expect(apiPaths.every(path=>path.startsWith("/app/imagefind/api/v1/"))).toBe(true); await page.locator(".video-card").first().click(); await expect(page.locator(".player-actions a.action")).toHaveAttribute("href","/app/imagefind/api/v1/videos/video-0/download?media_token=gateway-media-token"); await expect(page.locator(".player-actions a.action")).not.toHaveAttribute("href",/gateway-session-token/); await expect(page.locator(".player-back")).toBeVisible(); await page.goBack(); await page.getByRole("button",{name:"更多"}).click(); await expect(page.locator(".mobile-more-menu").getByText("退出登录",{exact:true})).toHaveCount(0); await page.locator(".sheet-close").click(); await page.setViewportSize({width:1024,height:768}); await page.locator(".avatar").click(); await expect(page.locator(".account-popover").getByText("退出登录",{exact:true})).toHaveCount(0); }); test("appearance follows the system and allows an explicit override",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await page.emulateMedia({colorScheme:"dark"}); await mockApi(page); await page.goto("/"); await expect(page.locator("html")).toHaveAttribute("data-theme","dark"); await openSettings(page,1024); await page.getByRole("button",{name:"任务与偏好"}).click(); const theme=page.getByRole("radiogroup",{name:"外观主题"}); await expect(theme.getByRole("radio",{name:"跟随系统"})).toHaveAttribute("aria-checked","true"); const lightRequest=page.waitForRequest(request=>request.url().endsWith("/api/v1/preferences")&&request.method()==="PATCH"&&(request.postDataJSON() as {theme?:string}).theme==="light"); await theme.getByRole("radio",{name:"浅色"}).click(); await lightRequest; await expect(page.locator("html")).toHaveAttribute("data-theme","light"); await theme.getByRole("radio",{name:"跟随系统"}).click(); await expect(page.locator("html")).toHaveAttribute("data-theme","dark"); await page.emulateMedia({colorScheme:"light"}); await expect(page.locator("html")).toHaveAttribute("data-theme","light"); await page.emulateMedia({colorScheme:"dark"}); await expect(page.locator("html")).toHaveAttribute("data-theme","dark"); const palette=await page.evaluate(()=>({background:getComputedStyle(document.body).backgroundColor,surface:getComputedStyle(document.querySelector(".panel:not(.hidden)")!).backgroundColor,color:getComputedStyle(document.body).color})); expect(palette).toEqual({background:"rgb(11, 15, 22)",surface:"rgb(19, 25, 35)",color:"rgb(237, 242, 248)"}); await page.evaluate(()=>window.scrollTo({top:0,behavior:"instant"})); await visualSnapshot(page,"settings-dark-1024.png"); }); test("appearance shortcut controls theme and cover privacy on desktop and mobile",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); await page.goto("/"); const trigger=page.getByRole("button",{name:/外观与隐私:/}); await expect(trigger).toBeVisible(); await expect(page.locator(".top-actions>.appearance-toolbar")).toHaveCount(1); const desktopOrder=await page.locator(".top-actions").evaluate(actions=>[...actions.children].map(element=>element.className)); expect(desktopOrder).toEqual(["top-upload","top-menu-wrap appearance-toolbar","top-menu-wrap","top-menu-wrap"]); await trigger.click(); const panel=page.getByRole("dialog",{name:"外观与隐私快捷设置"}); await expect(panel).toBeVisible(); await page.locator(".home-collection-card>span").evaluate(element=>{const image=document.createElement("img");image.className="thumb-image";image.alt="";element.append(image)}); const maskRequest=page.waitForRequest(request=>request.url().endsWith("/api/v1/preferences")&&request.method()==="PATCH"&&(request.postDataJSON() as {mask_covers?:boolean}).mask_covers===true); await panel.getByRole("switch",{name:"遮罩媒体封面"}).click(); await maskRequest; await expect(page.locator(".app-shell")).toHaveClass(/covers-masked/); await expect(page.locator(".home-collection-card .thumb-image")).toHaveCSS("filter",/blur\(22px\)/); await panel.getByRole("radio",{name:"深色"}).click(); await expect(page.locator("html")).toHaveAttribute("data-theme","dark"); await page.keyboard.press("Escape"); await expect(panel).toHaveCount(0); await page.setViewportSize({width:390,height:844}); await expect(trigger).toBeVisible(); const mobileCenters=await page.locator(".top-actions").evaluate(actions=>[...actions.querySelectorAll(".appearance-trigger,.notification-button,.avatar")].map(button=>button.getBoundingClientRect()).filter(box=>box.width>0&&box.height>0).map(box=>box.left+box.width/2)); expect(mobileCenters).toHaveLength(3); expect(mobileCenters).toEqual([...mobileCenters].sort((a,b)=>a-b)); await trigger.click(); await expect(panel).toBeVisible(); const box=await panel.boundingBox(); expect(box?.x).toBeGreaterThanOrEqual(11); expect((box?.x||0)+(box?.width||0)).toBeLessThanOrEqual(379); expect(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth)).toBe(true); await page.mouse.click(6,120); await expect(panel).toHaveCount(0); }); test("mobile more uses the dark app palette when following the system",async({page})=>{ await page.setViewportSize({width:390,height:844}); await page.emulateMedia({colorScheme:"dark"}); await mockApi(page); await page.goto("/"); await page.getByRole("button",{name:"更多"}).click(); const colors=await page.evaluate(()=>({sheet:getComputedStyle(document.querySelector(".mobile-more-menu")!).backgroundColor,tile:getComputedStyle(document.querySelector(".mobile-more-item")!).backgroundColor})); expect(colors).toEqual({sheet:"rgb(19, 25, 35)",tile:"rgba(0, 0, 0, 0)"}); await visualSnapshot(page,"mobile-more-dark.png",false); }); test("profile, collections, upload state and settings sections work on mobile", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockApi(page); await page.route("**/api/v1/uploads?*", route => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([{ id: "upload-1", filename: "large.mp4", source_id: source.id, status: "transferring", progress: .45, bytes_received: 45, size_bytes: 100, message: "传输到目标库 45%", relative_path: "", chunk_size: 10, total_chunks: 10, received_chunks: [0, 1, 2, 3] }]), })); await page.goto("/"); await expect(page.locator(".sidebar nav > .upload-status b")).toHaveText("1"); await page.locator(".sidebar nav > .upload-status").click(); await expect(page.getByRole("heading", { name: "传输中心" })).toBeVisible(); await expect(page.getByRole("button", { name: "继续上传" })).toBeVisible(); await expect(page.locator(".sidebar")).toBeHidden(); await page.goBack(); await page.locator(".mobile-profile-button").click(); await expect(page.getByRole("heading", { name: "管理员" })).toBeVisible(); await expect(page.locator(".topbar")).toBeHidden(); await expect(page.locator(".profile-stats article")).toHaveCount(4); await expect(page.locator(".profile-continue-grid .continue-card")).toHaveCount(8); await visualSnapshot(page,"mobile-profile.png"); await page.getByRole("button", { name: "个人资料" }).click(); await expect(page.getByLabel("昵称")).toHaveValue("管理员"); const autoplay=page.getByRole("switch",{name:"自动播放下一部"}); const coverMask=page.getByRole("switch",{name:"默认遮罩封面"}); await expect(autoplay).toHaveAttribute("aria-checked","true"); await expect(coverMask).toHaveAttribute("aria-checked","false"); await autoplay.click(); await expect(autoplay).toHaveAttribute("aria-checked","false"); await page.getByRole("button", { name: "更多" }).click(); await page.locator(".mobile-more-menu").getByText("合集", { exact: true }).click(); await expect(page.getByRole("heading", { name: "合集",exact:true })).toBeVisible(); await expect(page.locator(".topbar")).toBeHidden(); await page.getByRole("button", { name: /示例合集/ }).click(); await expect(page.locator(".collection-preview-video")).toHaveCount(8); await expect(page.locator(".collection-row-actions")).toHaveCount(0); await page.getByRole("button",{name:"管理合集"}).click(); await expect(page.locator(".collection-video-list > article")).toHaveCount(8); const removeCollection=page.locator(".collection-row-actions").first().getByRole("button",{name:"移出"}); const removeLayout=await removeCollection.evaluate(button=>({height:button.getBoundingClientRect().height,display:getComputedStyle(button).display,placeItems:getComputedStyle(button).placeItems})); expect(removeLayout.height).toBeGreaterThanOrEqual(44); expect(removeLayout.display).toBe("grid"); expect(removeLayout.placeItems).toBe("center"); await page.goBack(); await expect(page.locator(".collection-preview-directory")).toBeVisible(); await page.goBack(); await expect(page.locator(".collection-browser-grid")).toBeVisible(); await page.goBack(); await expect(page.getByRole("heading", { name: "管理员" })).toBeVisible(); await page.getByRole("button", { name: "更多" }).click(); await page.locator(".mobile-more-menu").getByText("设置", { exact: true }).click(); await expect(page.locator(".topbar")).toBeHidden(); await page.getByRole("button", { name: "存储", exact: true }).click(); await expect(page.locator(".storage-panel-v2").getByText("NAS 本地暂存 / 缓存")).toBeVisible(); await expect(page.locator(".storage-panel-v2").getByText("AI 索引", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "AI 与网络" }).click(); await expect(page.getByText("HTTP/HTTPS 下载代理")).toBeVisible(); await expect(page.getByLabel("PyPI 主镜像")).toHaveValue("https://pypi.tuna.tsinghua.edu.cn/simple"); const proxySwitch=page.getByRole("switch",{name:"使用下载代理"}); await expect(proxySwitch).toHaveAttribute("aria-checked","false"); await proxySwitch.click(); await expect(proxySwitch).toHaveAttribute("aria-checked","true"); await expect(page.getByText("已启用:用于 OpenVINO 等非国内镜像来源")).toBeVisible(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth); expect(overflow).toBeLessThanOrEqual(1); }); test("settings installs each AI model independently and keeps progress on its row",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); const modelStatus:Record={ visual_ready:false,ocr_ready:false,faces_ready:false,audio_ready:false,pending_videos:0, hf_endpoint:"https://huggingface.co",pip:{index_url:"https://pypi.tuna.tsinghua.edu.cn/simple",pytorch_index_url:"https://download.pytorch.org/whl/cpu"},proxy:{enabled:false,url:"",username:"",has_password:false}, component_versions:{},component_sizes:{visual:0,ocr:0,faces:0,audio:0},manifest:{}, accelerator:{selected:"AUTO",components:{},devices:[],cpu_threads:2},sources:{ visual_image:"https://huggingface.co/sentence-transformers/clip-ViT-B-32", visual_text:"https://huggingface.co/sentence-transformers/clip-ViT-B-32-multilingual-v1", ocr:"https://github.com/RapidAI/RapidOCR",audio:"https://huggingface.co/openai/whisper-small",faces:{}, }, installations:{ visual:{job_id:"visual-job",status:"queued",progress:0,message:"",error:null}, ocr:{job_id:"ocr-job",status:"running",progress:.42,message:"下载 OCR 权重",error:null}, faces:{job_id:"faces-job",status:"failed",progress:.2,message:"",error:"OpenVINO 下载失败"}, }, }; const submitted:string[]=[]; await page.route("**/api/v1/models?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(modelStatus)})); await page.route("**/api/v1/models/install",async route=>{ const component=(route.request().postDataJSON() as {component:string}).component; submitted.push(component); modelStatus.installations[component]={job_id:`${component}-job`,status:"queued",progress:0,message:"",error:null}; await route.fulfill({status:202,contentType:"application/json",body:JSON.stringify({job_id:`${component}-job`,job_ids:{[component]:`${component}-job`}})}); }); await page.goto("/"); await openSettings(page,1024); await expect(page.locator(".model-component")).toHaveCount(4); const visual=page.locator(".model-component").filter({hasText:"画面语义"}); const ocr=page.locator(".model-component").filter({hasText:"OCR 文字"}); const faces=page.locator(".model-component").filter({hasText:"人物识别"}); const audio=page.locator(".model-component").filter({hasText:"音频识别"}); await expect(visual.locator(".model-install-progress")).toContainText("等待"); await expect(ocr.locator(".model-install-progress")).toContainText("42%"); await expect(ocr).toContainText("下载 OCR 权重"); await expect(faces).toContainText("OpenVINO 下载失败"); await expect(faces.getByRole("button",{name:"重试"})).toBeVisible(); await expect(audio.getByRole("button",{name:"下载"})).toBeVisible(); await audio.getByRole("button",{name:"下载"}).click(); await expect.poll(()=>submitted).toEqual(["audio"]); await expect(audio.locator(".model-install-progress")).toContainText("等待"); const installMissing=page.getByRole("button",{name:"安装或修复缺失组件"}); await expect(installMissing).toHaveClass(/secondary/); await expect(installMissing).toBeDisabled(); }); test("settings repairs an installed model whose runtime is unavailable",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); const modelStatus:Record={ visual_ready:true,ocr_ready:true,faces_ready:true,audio_ready:true,pending_videos:17, operational_components:{visual:false,ocr:true,faces:true,audio:true}, hf_endpoint:"https://huggingface.co",pip:{index_url:"https://pypi.tuna.tsinghua.edu.cn/simple",pytorch_index_url:"https://download.pytorch.org/whl/cpu"},proxy:{enabled:false,url:"",username:"",has_password:false}, component_versions:{visual:"clip",ocr:"rapidocr",faces:"open-model-zoo",audio:"whisper"},component_sizes:{visual:100,ocr:100,faces:100,audio:100},manifest:{}, runtime_dependencies:{visual:{state:"missing"},ocr:{state:"ready"},faces:{state:"ready"},audio:{state:"ready"}}, runtime_environment:{state:"ready",size_bytes:1_500_000_000,components:["ocr","faces","audio"]},component_health:{},installations:{}, accelerator:{selected:"AUTO",components:{},devices:[],cpu_threads:2},sources:{ visual_image:"https://huggingface.co/sentence-transformers/clip-ViT-B-32", visual_text:"https://huggingface.co/sentence-transformers/clip-ViT-B-32-multilingual-v1", ocr:"https://github.com/RapidAI/RapidOCR",audio:"https://huggingface.co/openai/whisper-small",faces:{}, }, }; const submitted:string[]=[]; await page.route("**/api/v1/models?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(modelStatus)})); await page.route("**/api/v1/models/install",async route=>{ const component=(route.request().postDataJSON() as {component:string}).component; submitted.push(component); modelStatus.installations[component]={job_id:`${component}-repair`,status:"queued",progress:0,message:"",error:null}; await route.fulfill({status:202,contentType:"application/json",body:JSON.stringify({job_id:`${component}-repair`,job_ids:{[component]:`${component}-repair`}})}); }); await page.goto("/"); await openSettings(page,1024); const visual=page.locator(".model-component").filter({hasText:"画面语义"}); await expect(visual).toContainText("首次下载会安装运行环境"); await visual.getByRole("button",{name:"修复"}).click(); await expect.poll(()=>submitted).toEqual(["visual"]); await expect(visual.locator(".model-install-progress")).toContainText("等待"); }); test("settings exposes domestic pip source and an independent proxy switch",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); await page.goto("/"); await openSettings(page,390); await expect(page.getByLabel("PyPI 主镜像")).toHaveValue("https://pypi.tuna.tsinghua.edu.cn/simple"); await expect(page.getByLabel("PyTorch CPU 轮子源(可留空)")).toHaveValue("https://download.pytorch.org/whl/cpu"); const proxySwitch=page.getByRole("switch",{name:"使用下载代理"}); await expect(proxySwitch).toHaveAttribute("aria-checked","false"); await expect(page.getByText("已关闭:AI 下载直接连接目标站点")).toBeVisible(); await proxySwitch.click(); await expect(proxySwitch).toHaveAttribute("aria-checked","true"); await expect(page.getByText("已启用:用于 OpenVINO 等非国内镜像来源")).toBeVisible(); await page.getByRole("button",{name:/智能检测/}).click(); await page.getByRole("button",{name:/均衡/}).click(); const saveSpeech=page.waitForRequest(request=>request.url().endsWith("/api/v1/speech/config")&&request.method()==="PATCH"); await page.getByRole("button",{name:"保存识别策略"}).click(); expect((await saveSpeech).postDataJSON()).toMatchObject({language_policy:"auto",quality_profile:"balanced"}); const repairSpeech=page.waitForRequest(request=>request.url().endsWith("/api/v1/speech/reconcile")&&request.method()==="POST"); await page.getByRole("button",{name:"重建旧版音频索引"}).click(); await repairSpeech; await expect(page.getByText(/发现 2 部低质量转写并排队重做/)).toBeVisible(); expect(await page.evaluate(()=>document.documentElement.scrollWidth-innerWidth)).toBeLessThanOrEqual(1); }); test("resource panel explains and manually verifies legacy rclone processes",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); await page.goto("/"); await openSettings(page,1024); await page.locator(".settings-segments").getByRole("button",{name:"任务与偏好"}).click(); await expect(page.locator(".io-overview")).toContainText("应用数据卷利用率 21.4%"); await expect(page.locator(".io-overview")).toContainText("全局等待 32.2%(仅供参考)"); await expect(page.locator(".gpu-mode-panel")).toContainText("2 streams · batch 2"); const gpuModeRequest=page.waitForRequest(value=>value.url().endsWith("/system/resources")&&value.method()==="PATCH"); await page.locator(".gpu-mode-panel").getByRole("button",{name:/吞吐/}).click(); expect((await gpuModeRequest).postDataJSON()).toEqual({ai_gpu_mode:"throughput"}); await expect(page.locator(".rclone-reconcile")).toContainText("1 个旧实例正在安全核验"); await expect(page.locator(".rclone-reconcile")).toContainText("连续 3 次"); const request=page.waitForRequest(value=>value.url().endsWith("/system/resources/rclone/reconcile")&&value.method()==="POST"); await page.getByRole("button",{name:"立即安全核验"}).click(); await request; await expect(page.getByText(/仍在安全观察期/)).toBeVisible(); }); test("storage page separates AI, rclone and OpenList temporary usage",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); await page.route("**/api/v1/storage/usage*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({schema_version:2,collected_at:"2026-08-08T02:00:00Z",cached_seconds:30,disk:{total_bytes:4*1024**4,used_bytes:2*1024**4,available_bytes:2*1024**4},app_bytes:20*1024**3,categories:{database:{bytes:1024**3,available:true},ai_index:{bytes:2*1024**3,available:true,mode:"pgvector"},models:{bytes:4*1024**3,available:true},thumbnails:{bytes:1024**3,available:true},preview_cache:{bytes:1024**3,available:true,children:{preview_cache:{bytes:900*1024**2,available:true},query_images:{bytes:124*1024**2,available:true}}},remote_cache:{bytes:3*1024**3,available:true,active_bytes:1024**3,reclaimable_bytes:2*1024**3,children:{remote_media_cache:{bytes:1024**3,available:true,active_bytes:512*1024**2,reclaimable_bytes:512*1024**2},rclone_vfs_cache:{bytes:2*1024**3,available:true,active_bytes:0,reclaimable_bytes:2*1024**3},rclone_runtime:{bytes:1024,available:true}}},upload_staging:{bytes:8*1024**3,available:true,children:{upload_receive:{bytes:0,available:true},download_staging:{bytes:0,available:true},encryption_work:{bytes:0,available:true},openlist_local_staging:{bytes:8*1024**3,available:true,locations:[{source_id:"source-1",name:"OpenList",path:"/volume/stage",bytes:8*1024**3,available:true}]}}},other:{bytes:0,available:true}}})})); await page.goto("/"); await openSettings(page,390); await page.locator(".settings-segments").getByRole("button",{name:"存储",exact:true}).click(); await expect(page.getByText("AI 远程媒体",{exact:true})).toBeVisible(); await expect(page.getByText("rclone VFS 缓存",{exact:true})).toBeVisible(); await expect(page.getByText("OpenList 本地中转",{exact:true})).toBeVisible(); await expect(page.getByText("1 个 OpenList 中转路径")).toBeVisible(); const cleanup=page.waitForRequest(request=>request.url().endsWith("/system/resources/cache/cleanup")&&request.method()==="POST"); await page.getByRole("button",{name:"清理旧 rclone 缓存"}).click(); await page.getByRole("button",{name:"继续"}).click(); expect((await cleanup).postDataJSON()).toEqual({targets:["rclone_vfs"]}); expect(await page.evaluate(()=>document.documentElement.scrollWidth-innerWidth)).toBeLessThanOrEqual(1); }); test("remote source capacity supports a truthful manual override when OpenList omits quota",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); const cloud={...source,kind:"webdav",name:"yidongpan/test",config:{driver:"alist",storage_backend:"openlist_native",writable:true}}; await page.route("**/api/v1/sources",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify([cloud])})); let capacityResponse={source_id:"source-1",scope:"remote",status:"unsupported",total_bytes:0,used_bytes:0,available_bytes:0,provider:"rclone",reason:"此媒体来源未提供容量配额",collected_at:"2026-08-09T08:00:00Z",cached_seconds:30}; await page.route("**/api/v1/sources/source-1/capacity*",route=>{if(route.request().method()==="PATCH"){const body=route.request().postDataJSON() as {total_bytes:number;available_bytes:number};capacityResponse={...capacityResponse,status:"available",total_bytes:body.total_bytes,used_bytes:body.total_bytes-body.available_bytes,available_bytes:body.available_bytes,provider:"manual",reason:"管理员手工设置;不会自动随云盘变化"}}return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(capacityResponse)})}); await page.goto("/"); await openSettings(page,390); await page.locator(".settings-segments").getByRole("button",{name:"存储",exact:true}).click(); const capacity=page.locator(".source-capacities article").filter({hasText:"yidongpan/test"}); await expect(capacity).toContainText("未提供配额"); await capacity.getByRole("button",{name:"设置容量"}).click(); await capacity.getByLabel("总容量(TiB)").fill("8"); await capacity.getByLabel("可用容量(TiB)").fill("6"); const request=page.waitForRequest(value=>value.url().includes("/sources/source-1/capacity")&&value.method()==="PATCH"); await capacity.getByRole("button",{name:"保存容量"}).click(); expect((await request).postDataJSON()).toEqual({total_bytes:8*1024**4,available_bytes:6*1024**4}); await expect(capacity).toContainText("手工设置 · 不会自动同步云盘"); await expect(capacity).toContainText("可用 6.00 TB"); await expect(capacity).not.toContainText("NAS"); }); test("upload completion stays on the current page and exposes WebDAV transfer stages",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); const webdav={...source,kind:"webdav",name:"家庭 WebDAV",config:{base_url:"https://dav.example",writable:true}}; await page.route("**/api/v1/sources",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify([webdav])})); await page.route("**/api/v1/uploads",async route=>{ const body=route.request().method()==="POST" ? {id:"upload-new",filename:"voice.mp4",source_id:source.id,status:"receiving",progress:0,bytes_received:0,size_bytes:5,relative_path:"",chunk_size:5,total_chunks:1,received_chunks:[]} : []; await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(body)}); }); await page.goto("/"); await page.locator(".sidebar nav > .upload-status").click(); await page.locator(".drop-zone input[type=file]").setInputFiles({name:"voice.mp4",mimeType:"video/mp4",buffer:Buffer.from("voice")}); await page.getByRole("button",{name:"开始上传"}).click(); await expect(page.locator(".toast-host")).toContainText("正在后台转存到 家庭 WebDAV"); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); await expect(page.getByRole("heading",{name:"上传中心"})).toHaveCount(0); await expect(page.getByRole("heading",{name:"上传到私有媒体库"})).toBeHidden(); await page.route("**/api/v1/uploads?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify([{id:"upload-new",filename:"voice.mp4",source_id:source.id,status:"transferring",stage:"external_copying",stage_label:"正在复制到云盘",progress:.45,bytes_received:5,size_bytes:5,message:"传输到目标库 45%",relative_path:"",chunk_size:5,total_chunks:1,received_chunks:[0],stages:[ {key:"receiving",state:"completed",progress:1,label:"上传至 ImageFind"}, {key:"local_processing",state:"completed",progress:1,label:"本地校验与处理"}, {key:"remote",state:"active",progress:.45,label:"云盘复制与校验"}, {key:"cataloging",state:"pending",progress:0,label:"加入媒体库并排队 AI"}, ]}])})); const globalUpload=page.locator(".sidebar nav > .upload-status"); await expect(globalUpload).toHaveAttribute("aria-label",/传输到目标库 45%/); await globalUpload.click(); await expect(page.getByRole("heading",{name:"传输中心"})).toBeVisible(); await expect(page.locator(".transfer-stages>span")).toHaveCount(4); await expect(page.locator(".transfer-stages")).toContainText("上传至 ImageFind"); await expect(page.locator(".transfer-stages")).toContainText("本地校验与处理"); await expect(page.locator(".transfer-stages")).toContainText("云盘复制与校验"); await expect(page.locator(".transfer-stages")).toContainText("加入媒体库并排队 AI"); await expect(page.locator(".transfer-stages")).toContainText("45%"); }); test("upload center paginates history while global state keeps resumable tasks",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); const history=Array.from({length:23},(_,index)=>({ id:`history-${String(index).padStart(2,"0")}`,filename:index===22?"resume.mp4":`history-${index}.mp4`,title:index===22?"断点任务":`历史任务 ${index}`, source_id:source.id,status:index===22?"receiving":"completed",progress:index===22?.4:1,bytes_received:index===22?2:10,size_bytes:index===22?6:10, message:index===22?"浏览器上传 40%":"已完成",relative_path:"",chunk_size:10,total_chunks:1,received_chunks:index===22?[]:[0], })); const requestedPages:number[]=[]; await page.route("**/api/v1/uploads?*",route=>{ const url=new URL(route.request().url());const current=Number(url.searchParams.get("page")||1);requestedPages.push(current); const start=(current-1)*10;const body={items:history.slice(start,start+10),status_items:[history[22]],active_count:1,failed_count:0,page:current,page_size:10,total:23,pages:3}; return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(body)}); }); await page.goto("/"); await expect(page.locator(".sidebar nav > .upload-status b")).toHaveText("1"); await page.locator(".sidebar nav > .upload-status").click(); await expect(page.locator(".transfer-list>article")).toHaveCount(10); await expect(page.locator(".transfer-tabs")).toContainText("上传记录 1"); await expect(page.locator(".transfer-summary")).toContainText("进行中 1"); await expect(page.locator(".transfer-summary")).toContainText("历史 22"); await expect(page.locator(".transfer-pagination")).toContainText("第 1 / 3 页"); await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollHeight)).toBeLessThan(5000); await page.locator(".transfer-pagination").getByRole("button",{name:"下一页"}).click(); await expect(page.locator(".transfer-pagination")).toContainText("第 2 / 3 页"); await expect(page.locator(".transfer-list>article")).toHaveCount(10); await expect(page.locator(".transfer-list")).toContainText("历史任务 10"); await expect(page.locator(".transfer-list")).not.toContainText("历史任务 0"); await page.getByRole("button",{name:"刷新",exact:true}).click(); await expect.poll(()=>requestedPages.at(-1)).toBe(2); await page.getByRole("button",{name:"继续上传"}).click(); await page.locator(".drop-zone input[type=file]").setInputFiles({name:"resume.mp4",mimeType:"video/mp4",buffer:Buffer.from("resume")}); await expect(page.getByLabel("视频标题(可选)")).toHaveValue("断点任务"); await page.getByRole("button",{name:"关闭"}).click(); for(const width of [320,390]){ await page.setViewportSize({width,height:780}); await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth-innerWidth)).toBeLessThanOrEqual(1); for(const button of await page.locator(".transfer-pagination button").all()){ const box=await button.boundingBox();expect(box).not.toBeNull();expect(box!.height).toBeGreaterThanOrEqual(44); } } }); test("mobile upload records clean noisy names, expose status hierarchy and stay contained",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); const noisy="dc5d56e813283316e7b37705e400bf4a_logo.mp4.8951e586f4d7aebf41f43fac535449c8.mp4"; const tasks=[ {id:"upload-active",filename:noisy,source_id:source.id,status:"transferring",progress:.45,bytes_received:45,size_bytes:100,message:"传输到目标库 45%",relative_path:"",chunk_size:10,total_chunks:10,received_chunks:[0,1,2,3]}, {id:"upload-failed",filename:"failed.mp4",source_id:source.id,status:"failed",progress:.2,bytes_received:20,size_bytes:100,error:"目标目录不可写",relative_path:"",chunk_size:10,total_chunks:10,received_chunks:[0,1],failure_stage:"transfer",can_retry:true}, {id:"upload-history",filename:"ssnn3.mp4.17026b156c943689dbb4596e735e8598.mp4",source_id:source.id,status:"completed",progress:1,bytes_received:100,size_bytes:100,message:"已完成",relative_path:"",chunk_size:10,total_chunks:10,received_chunks:[0,1,2,3,4,5,6,7,8,9]}, ]; await page.route("**/api/v1/uploads?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({items:tasks,status_items:tasks.slice(0,2),active_count:1,failed_count:1,page:1,page_size:10,total:3,pages:1})})); await page.goto("/"); await page.locator(".sidebar nav > .upload-status").click(); await expect(page.locator(".transfer-main strong").first()).toHaveText("logo"); await expect(page.locator(".completed-history .transfer-main strong")).toHaveText("ssnn3"); await expect(page.locator(".transfer-list")).toContainText(noisy); await expect(page.locator(".transfer-summary")).toContainText("进行中 1"); await expect(page.locator(".transfer-summary")).toContainText("失败 1"); await expect(page.locator(".transfer-summary")).toContainText("历史 1"); await expect(page.locator(".completed-history .transfer-completed")).toHaveText(/AI 识别已排队/); const transferGeometry=await page.evaluate(()=>{const content=document.querySelector(".content")!;const list=document.querySelector(".transfer-list")!;return {content:[content.scrollWidth,content.clientWidth],list:[list.scrollWidth,list.clientWidth],document:document.documentElement.scrollWidth,viewport:innerWidth}}); expect(transferGeometry.content[0]).toBeLessThanOrEqual(transferGeometry.content[1]+1); expect(transferGeometry.list[0]).toBeLessThanOrEqual(transferGeometry.list[1]+1); expect(transferGeometry.document).toBeLessThanOrEqual(transferGeometry.viewport+1); await page.goBack(); await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible(); await page.getByRole("button",{name:"搜索",exact:true}).first().click(); const filterHeights=await page.locator(".recognition-filter button").evaluateAll(buttons=>buttons.map(button=>button.getBoundingClientRect().height)); expect(filterHeights.length).toBeGreaterThan(0); expect(filterHeights.every(height=>height>=44)).toBe(true); await page.getByRole("button",{name:"更多"}).click(); await page.locator(".mobile-more-menu").getByRole("button",{name:"设置"}).click(); const modelActionHeights=await page.locator(".model-components article>button").evaluateAll(buttons=>buttons.map(button=>button.getBoundingClientRect().height)); expect(modelActionHeights.length).toBeGreaterThan(0); expect(modelActionHeights.every(height=>height>=44)).toBe(true); }); test("multi-file upload keeps an independent optional title for every video",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); const submitted:Array>=[]; await page.route("**/api/v1/uploads",async route=>{ if(route.request().method()!=="POST"){await route.fallback();return} const request=route.request().postDataJSON() as Record;submitted.push(request); await route.fulfill({status:201,contentType:"application/json",body:JSON.stringify({id:`upload-${submitted.length}`,...request,status:"receiving",progress:0,bytes_received:0,chunk_size:Number(request.size_bytes),total_chunks:1,received_chunks:[]})}); }); await page.goto("/"); await page.locator(".top-upload .upload-status").click(); await page.locator(".drop-zone input[type=file]").setInputFiles([ {name:"first.mp4",mimeType:"video/mp4",buffer:Buffer.from("one")}, {name:"second.mp4",mimeType:"video/mp4",buffer:Buffer.from("two")}, ]); const titles=page.getByLabel("视频标题(可选)"); await expect(titles).toHaveCount(2); await titles.nth(0).fill("第一部自定义标题"); await titles.nth(1).fill("第二部自定义标题"); await page.getByRole("button",{name:"开始上传"}).click(); await expect.poll(()=>submitted.length).toBe(2); expect(submitted.map(item=>item.title)).toEqual(["第一部自定义标题","第二部自定义标题"]); }); test("recycle bin has loading, empty, restore and permanent-delete states",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); let items:Array>=[]; let releaseInitialTrash!:()=>void; const initialTrashGate=new Promise(resolve=>{releaseInitialTrash=resolve}); let initialTrashRequest=true; await page.route("**/api/v1/trash**",async route=>{ const url=new URL(route.request().url()); if(route.request().method()==="GET"){ if(initialTrashRequest){initialTrashRequest=false;await initialTrashGate} await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(items)});return; } if(url.pathname.endsWith("/restore")){ items=[];await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({key:"restored/movie.mp4"})});return; } if(route.request().method()==="DELETE"){ items=[];await route.fulfill({status:204,body:""});return; } await route.fallback(); }); await page.goto("/"); await page.getByRole("button",{name:"更多"}).click(); await page.locator(".mobile-more-menu").getByRole("button",{name:"资料库"}).click(); await page.getByRole("button",{name:"回收站",exact:true}).click(); const dialog=page.getByRole("dialog",{name:"回收站"}); await expect(dialog).toBeVisible(); await expect(dialog.getByText("正在读取回收站…")).toBeVisible(); releaseInitialTrash(); await expect(dialog.getByRole("heading",{name:"回收站是空的"})).toBeVisible(); expect(await page.evaluate(()=>document.body.style.overflow)).toBe("hidden"); const deleted={id:"trash-1",source_id:source.id,display_name:"movie.mp4",original_key:"private/movie.mp4",size_bytes:1024,deleted_at:"2026-07-31T10:00:00Z",expires_at:"2026-08-30T10:00:00Z"}; items=[deleted];await dialog.getByRole("button",{name:"刷新"}).click(); await expect(dialog.getByText("private/movie.mp4")).toBeVisible(); await dialog.getByRole("button",{name:"恢复",exact:true}).click(); await expect(dialog.getByRole("heading",{name:"回收站是空的"})).toBeVisible(); items=[deleted];await dialog.getByRole("button",{name:"刷新"}).click(); await dialog.getByRole("button",{name:"永久删除",exact:true}).click(); await page.getByRole("alertdialog").getByRole("button",{name:"继续"}).click(); await expect(dialog.getByRole("heading",{name:"回收站是空的"})).toBeVisible(); await dialog.getByRole("button",{name:"关闭回收站"}).click(); await expect(dialog).toBeHidden(); expect(await page.evaluate(()=>document.body.style.overflow)).toBe(""); }); test("AI status never renders placeholder conclusions before the response arrives",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); let release!:()=>void;const gate=new Promise(resolve=>{release=resolve}); const installed={visual_ready:true,ocr_ready:true,faces_ready:true,audio_ready:true,pending_videos:3,operational_components:{visual:true,ocr:true,faces:true,audio:true},component_versions:{visual:"clip",ocr:"rapidocr",faces:"omz",audio:"whisper"},component_sizes:{visual:100,ocr:100,faces:100,audio:100},manifest:{version:"test"},runtime_dependencies:{visual:{state:"ready"},ocr:{state:"ready"},faces:{state:"ready"},audio:{state:"ready"}},runtime_environment:{state:"ready",size_bytes:1024,components:["visual","ocr","faces","audio"]},component_health:{},installations:{},accelerator:{selected:"GPU.0",devices:["GPU.0"],device_names:{"GPU.0":"Intel UHD"},cpu_threads:2,components:{visual:{state:"ready",actual_device:"GPU.0"},ocr:{state:"ready",actual_device:"GPU.0"},faces:{state:"ready",actual_device:"GPU.0"},audio:{state:"ready",actual_device:"GPU.0"}}},sources:{},proxy:{enabled:false,url:"",username:"",has_password:false},pip:{index_url:"https://pypi.tuna.tsinghua.edu.cn/simple",pytorch_index_url:""}}; await page.route("**/api/v1/models?*",async route=>{await gate;await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(installed)})}); await page.goto("/"); await openSettings(page,1024); await expect(page.getByText("正在读取模型、运行环境与推理设备…")).toBeVisible(); await expect(page.locator(".model-panel").getByText("未安装",{exact:true})).toHaveCount(0); await expect(page.locator(".model-panel").getByText("CPU 回退",{exact:true})).toHaveCount(0); release(); await expect(page.locator(".model-component")).toHaveCount(4); await expect(page.locator(".accelerator-card")).toContainText("Intel GPU"); await expect(page.getByText("3 部",{exact:true})).toBeVisible(); }); test("AI status retains the last verified device after a model is unloaded",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); const installed={visual_ready:true,ocr_ready:true,faces_ready:true,audio_ready:true,pending_videos:0,operational_components:{visual:true,ocr:true,faces:true,audio:true},component_versions:{visual:"clip",ocr:"rapidocr",faces:"omz",audio:"whisper"},component_sizes:{visual:100,ocr:100,faces:100,audio:100},manifest:{version:"test"},runtime_dependencies:{visual:{state:"ready"},ocr:{state:"ready"},faces:{state:"ready"},audio:{state:"ready"}},runtime_environment:{state:"ready",size_bytes:1024,components:["visual","ocr","faces","audio"]},component_health:{},installations:{},accelerator:{selected:"Intel GPU 优先(首次推理时加载)",devices:["GPU.0"],device_names:{"GPU.0":"Intel UHD"},cpu_threads:2,components:{visual:{state:"not_loaded",device:null,requested_device:"GPU.0",actual_device:"GPU.0",execution_devices:["GPU.0"],last_verified_at:"2026-08-03T01:00:00Z"},ocr:{state:"not_loaded",device:null,requested_device:"GPU.0",actual_device:"CPU",execution_devices:["CPU"],last_verified_at:"2026-08-03T01:01:00Z"},faces:{state:"not_loaded",device:null,requested_device:null,actual_device:null,execution_devices:[],last_verified_at:null},audio:{state:"ready",actual_device:"GPU.0",execution_devices:["GPU.0"]}}},sources:{},proxy:{enabled:false,url:"",username:"",has_password:false},pip:{index_url:"https://pypi.tuna.tsinghua.edu.cn/simple",pytorch_index_url:""}}; await page.route("**/api/v1/models?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(installed)})); await page.goto("/"); await openSettings(page,1024); const accelerator=page.locator(".accelerator-card"); await expect(accelerator).toContainText("上次:Intel GPU"); await expect(accelerator).toContainText("上次:CPU"); await expect(accelerator).toContainText("首次任务时加载"); }); test("search supports six recognition types, timed audio excerpts and image filter correction",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); const searchBodies:unknown[]=[]; await page.route("**/api/v1/query-images",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({id:"query-image"})})); await page.route("**/api/v1/search",async route=>{ searchBodies.push(route.request().postDataJSON()); await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({items:[{ frame_id:"audio-video-0-65000",video_id:"video-0",display_name:videos[0].title,source_name:source.name,source_kind:"local", timestamp_ms:65000,segment_start_ms:65000,segment_end_ms:68000,thumbnail_url:"",playback_url:videos[0].playback_url, score:.91,match_sources:["audio"],match_details:[{type:"audio",text:"远处传来海浪声音",start_ms:65000,end_ms:68000}],catalog_code:"PRIVATE-001", }]})}); }); await page.goto("/"); await page.getByRole("button",{name:"搜索"}).click(); const coverage=page.locator(".search-coverage"); await expect(coverage).toContainText("音频可搜索覆盖率 50%"); await expect(coverage).toContainText("4 / 8 部当前可搜索 · 其中 5 部已用当前模型处理"); const recognition=page.getByRole("group",{name:"识别类型"}); for(const name of ["画面语义","OCR 文字","人物识别","音频识别","字幕","影片资料"]){ await expect(recognition.getByRole("button",{name})).toBeVisible(); } await recognition.getByRole("button",{name:"音频识别"}).click(); await page.getByLabel("搜索内容").fill("海浪声音"); await page.getByRole("form",{name:"移动端搜索"}).getByRole("button",{name:"搜索",exact:true}).click(); await expect(page.locator(".match-excerpt")).toHaveText("远处传来海浪声音"); await expect(page.locator(".search-result")).toContainText("1:05–1:08"); expect(searchBodies.at(-1)).toMatchObject({text:"海浪声音",recognition_types:["audio"]}); await page.locator('input[type="file"][accept="image/*"]').setInputFiles({name:"scene.png",mimeType:"image/png",buffer:Buffer.from("image")}); await expect(recognition.getByRole("button",{name:"画面语义"})).toHaveAttribute("aria-pressed","true"); await expect(recognition.getByRole("button",{name:"音频识别"})).toHaveAttribute("aria-pressed","false"); await expect.poll(()=>searchBodies.filter(body=>body.image_id==="query-image").at(-1)).toMatchObject({image_id:"query-image",recognition_types:["visual"]}); }); test("desktop primary text does not regress to undersized labels",async({page})=>{ await page.setViewportSize({width:1440,height:900}); await mockApi(page); await page.goto("/"); const undersized=await page.locator(".video-card .video-info h3,.video-card .video-info p,.video-card .duration,.chip-row button,.sidebar nav button").evaluateAll(elements=>elements.map(element=>({text:element.textContent?.trim(),size:Number.parseFloat(getComputedStyle(element).fontSize)})).filter(item=>item.size<11)); expect(undersized).toEqual([]); }); test("thumbnails expose loading, missing and error placeholders without broken images",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); const thumbnailVideos=videos.slice(0,3).map((video,index)=>({...video,thumbnail_url:index===2?undefined:`/api/v1/frames/${index===0?"slow":"broken"}/thumbnail`})); await page.route("**/api/v1/videos?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(thumbnailVideos)})); await page.route("**/api/v1/home?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({video_count:thumbnailVideos.length,updated_at:"2026-07-27T10:00:00Z",recent:{id:"recent",title:"最近新增",total:thumbnailVideos.length,items:thumbnailVideos},collections:{total:0,items:[]},categories:[],people:[],unorganized:null})})); const pixel=Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=","base64"); await page.route("**/api/v1/frames/**",async route=>{ if(route.request().url().includes("/slow/")){ await new Promise(resolve=>setTimeout(resolve,350)); await route.fulfill({status:200,contentType:"image/png",body:pixel}); }else await route.fulfill({status:404,contentType:"application/json",body:JSON.stringify({detail:"missing"})}); }); await page.goto("/",{waitUntil:"domcontentloaded"}); await expect(page.locator(".video-card").nth(0).getByText("封面加载中")).toBeVisible(); await expect(page.locator(".video-card").nth(1).getByText("封面加载失败")).toBeVisible(); await expect(page.locator(".video-card").nth(2).getByText("暂无封面")).toBeVisible(); await expect(page.locator(".video-card").nth(0).locator(".thumb-placeholder")).toHaveCount(0); await expect(page.locator(".video-card img[src]")).toHaveCount(2); }); test("player deletion chooses source handling and requires a second confirmation",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); await page.goto("/"); await page.locator(".video-card").first().click(); await expect(page.getByRole("button",{name:"删除视频"})).toBeVisible(); await page.getByRole("button",{name:"删除视频"}).click(); const choice=page.getByRole("alertdialog"); await expect(choice).toContainText("只从媒体库移除"); await choice.getByRole("button",{name:"仅移出媒体库"}).click(); const dialog=page.getByRole("alertdialog"); await expect(dialog).toContainText("源文件保持不变"); const deletion=page.waitForRequest(request=>request.method()==="DELETE"&&request.url().includes("/api/v1/videos/video-0")); await dialog.getByRole("button",{name:"继续"}).click(); expect((await deletion).url()).toContain("delete_source=false"); await expect(page.locator(".toast-host")).toContainText("视频已从媒体库移除,索引与缓存已清理"); }); test("transfer center exposes upload and system aria2 download flows",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); const activeDownload={id:"download-1",kind:"url",display_name:"movie.mp4",source_id:source.id,relative_path:"downloads",status:"downloading",progress:.36,total_bytes:1000,completed_bytes:360,download_speed:20,error:null,files:[],created_at:"2026-07-31T10:00:00Z"}; await page.route("**/api/v1/downloads",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify([activeDownload])})); await page.goto("/"); await expect(page.locator(".sidebar nav > .upload-status")).toHaveAttribute("aria-label",/movie\.mp4 · 后台下载 36%/); await expect(page.locator(".sidebar nav > .upload-status b")).toHaveText("1"); await page.locator(".sidebar nav > .upload-status").click(); await expect(page.getByRole("heading",{name:"传输中心"})).toBeVisible(); await page.locator(".transfer-tabs").getByRole("button",{name:/后台下载/}).click(); await expect(page.locator(".download-list")).toContainText("movie.mp4"); await page.getByRole("button",{name:"新建下载"}).click(); await expect(page.getByRole("heading",{name:"新建后台下载"})).toBeVisible(); await expect(page.getByPlaceholder("https://…、ftp://… 或 magnet:?…")).toBeVisible(); await expect(page.getByRole("button",{name:"选择 .torrent 文件"})).toBeVisible(); }); test("continue watching covers use gateway media auth and loading indicators stay centered",async({page})=>{ await page.setViewportSize({width:390,height:844}); await page.addInitScript(()=>{window.__IMAGEFIND_BASE__="/app/imagefind/"}); await mockApi(page,"gateway"); const continued={...videos[0],thumbnail_url:"/api/v1/frames/continue-frame/thumbnail"}; await page.route("**/api/v1/videos?*",route=>{ const url=new URL(route.request().url()); const body=url.searchParams.get("continue_only")==="true"?[continued]:[]; return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(body)}); }); let releaseThumbnail!:()=>void; const thumbnailGate=new Promise(resolve=>{releaseThumbnail=resolve}); const pixel=Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=","base64"); await page.route("**/api/v1/frames/continue-frame/thumbnail?*",async route=>{ await thumbnailGate; await route.fulfill({status:200,contentType:"image/png",headers:{"Cache-Control":"public, max-age=3600"},body:pixel}); }); let releaseStream!:()=>void; const streamGate=new Promise(resolve=>{releaseStream=resolve}); await page.route("**/api/v1/videos/video-0/stream?*",async route=>{ await streamGate; await route.fulfill({status:200,contentType:"video/mp4",body:Buffer.alloc(0)}); }); await page.goto("/app/imagefind/"); const coverRequest=page.waitForRequest(request=>request.url().includes("/frames/continue-frame/thumbnail")); await page.locator(".mobile-profile-button").click(); await coverRequest; const card=page.locator(".profile-continue-grid .continue-card"); await expect(card).toHaveCount(1); const image=card.locator(".thumb-image"); await expect(image).toHaveAttribute("loading","eager"); await expect(image).toHaveAttribute("src",/\/app\/imagefind\/api\/v1\/frames\/continue-frame\/thumbnail\?media_token=gateway-media-token/); const coverAlignment=await card.locator(".thumb-placeholder").evaluate(element=>{ const box=element.getBoundingClientRect(); const icon=element.querySelector("svg")!.getBoundingClientRect(); return { horizontal:Math.abs(icon.left+icon.width/2-(box.left+box.width/2)), vertical:Math.abs((icon.top+element.querySelector("small")!.getBoundingClientRect().bottom)/2-(box.top+box.height/2)), }; }); expect(coverAlignment.horizontal).toBeLessThan(1); expect(coverAlignment.vertical).toBeLessThan(3); releaseThumbnail(); await expect(card.locator(".thumb-media")).toHaveClass(/loaded/); await expect(card.locator(".thumb-placeholder")).toHaveCount(0); await page.locator(".nav-item-1").click(); await page.locator(".mobile-profile-button").click(); await expect(card.locator(".thumb-media")).toHaveClass(/loaded/); await expect(card.locator(".thumb-placeholder")).toHaveCount(0); const streamRequest=page.waitForRequest(request=>request.url().includes("/videos/video-0/stream")); await card.click(); await streamRequest; const playerLoading=page.locator(".player-center-status"); await expect(playerLoading).toBeVisible(); const playerAlignment=await playerLoading.evaluate(element=>{ const stage=element.closest(".player-stage")!.getBoundingClientRect(); const content=element.getBoundingClientRect(); const icon=element.querySelector("svg")!.getBoundingClientRect(); return { contentX:Math.abs(content.left+content.width/2-(stage.left+stage.width/2)), contentY:Math.abs(content.top+content.height/2-(stage.top+stage.height/2)), iconX:Math.abs(icon.left+icon.width/2-(stage.left+stage.width/2)), }; }); expect(Math.max(...Object.values(playerAlignment))).toBeLessThan(1); releaseStream(); }); test("player attempts compatible HLS only once after native playback fails",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); let previewRequests=0; await page.route("**/api/v1/videos/video-0/preview",async route=>{ previewRequests+=1; await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({playlist_url:"/api/v1/previews/player123/index.m3u8"})}); }); await page.goto("/"); await page.locator(".video-card").first().click(); await expect.poll(()=>previewRequests).toBe(1); await page.locator("video").dispatchEvent("error"); await page.locator("video").dispatchEvent("error"); await page.waitForTimeout(100); expect(previewRequests).toBe(1); await expect(page.locator(".custom-player")).toHaveAttribute("data-playback-source","compatible"); }); test("desktop upload center opens the task list while the top action opens the picker",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); await page.goto("/"); await page.locator(".sidebar nav > .upload-status").click(); await expect(page.getByRole("heading",{name:"传输中心"})).toBeVisible(); await expect(page.getByRole("heading",{name:"上传到私有媒体库"})).toHaveCount(0); await page.locator(".top-upload .upload-status").click(); await expect(page.getByRole("heading",{name:"上传到私有媒体库"})).toBeVisible(); }); test("background jobs paginate with readable rows and stable page requests",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); const jobs=Array.from({length:23},(_,index)=>({id:`job-${index}`,kind:index%2?"index_video":"transcribe_audio",status:index===0?"failed":index%5?"completed":"running",progress:index===0?.35:index%5?1:.46,message:index%5?"任务已完成":"正在提取音轨并识别",error:index===0?"模型服务暂时不可用":undefined,attempts:index%3+1,created_at:`2026-07-28T10:${String(index).padStart(2,"0")}:00Z`})); const requestedPages:number[]=[]; const requestedLanes:string[]=[]; const retried:string[]=[]; const bulkRetried:string[]=[]; const cancelled:string[]=[]; await page.route("**/api/v1/jobs?*",route=>{ const url=new URL(route.request().url());const current=Number(url.searchParams.get("page")||1);requestedPages.push(current);requestedLanes.push(url.searchParams.get("lane")||"all"); const pageSize=10;const items=jobs.slice((current-1)*pageSize,current*pageSize); return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({items,page:current,page_size:pageSize,total:jobs.length,pages:3,retryable_failed_count:url.searchParams.get("lane")==="transfer"?0:1})}); }); await page.route("**/api/v1/jobs/retry-failed*",route=>{bulkRetried.push(new URL(route.request().url()).searchParams.get("lane")||"all");return route.fulfill({status:202,contentType:"application/json",body:JSON.stringify({lane:"all",retried:1,skipped:0,job_ids:["job-bulk-retried"]})})}); await page.route("**/api/v1/jobs/job-0/retry",route=>{retried.push("job-0");return route.fulfill({status:202,contentType:"application/json",body:JSON.stringify({job_id:"job-retried",retried_from:"job-0"})})}); await page.route("**/api/v1/jobs/job-5/cancel",route=>{cancelled.push("job-5");return route.fulfill({status:202,contentType:"application/json",body:JSON.stringify({job_id:"job-5",cancel_requested:true})})}); await page.goto("/"); await openSettings(page,1024); await page.getByRole("button",{name:"任务与偏好"}).click(); await expect(page.locator(".mini-job")).toHaveCount(10); await expect(page.locator(".job-lane-filters>button")).toHaveCount(5); await expect(page.locator(".job-lane-badge").first()).toHaveText("AI 识别"); await expect(page.getByText("第 1 / 3 页")).toBeVisible(); const firstRow=await page.locator(".mini-job").first().boundingBox(); expect(firstRow?.height).toBeGreaterThanOrEqual(75); await page.getByRole("button",{name:"重试全部失败 (1)"}).click(); const bulkConfirm=page.getByRole("alertdialog"); await expect(bulkConfirm).toContainText("只会重新排队尚未被后续任务取代的失败记录"); await bulkConfirm.getByRole("button",{name:"继续"}).click(); await expect.poll(()=>bulkRetried).toEqual(["all"]); await expect(page.locator(".status-line")).toContainText("已重新排队 1 个失败任务"); await page.getByRole("button",{name:"重试识别视频音频"}).click(); await expect.poll(()=>retried).toEqual(["job-0"]); await expect(page.locator(".status-line")).toContainText("识别视频音频已重新排队"); await page.getByRole("button",{name:"取消解析视频资料"}).click(); await expect.poll(()=>cancelled).toEqual(["job-5"]); await expect(page.locator(".status-line")).toContainText("解析视频资料正在安全停止"); await page.getByRole("navigation",{name:"后台任务分页"}).getByRole("button",{name:"下一页"}).click(); await expect(page.getByText("第 2 / 3 页")).toBeVisible(); await expect(page.locator(".mini-job")).toHaveCount(10); expect(requestedPages).toContain(2); await page.getByRole("navigation",{name:"后台任务通道"}).getByRole("button",{name:"上传转存"}).click(); await expect.poll(()=>requestedLanes.at(-1)).toBe("transfer"); await expect(page.getByRole("button",{name:/重试全部失败/})).toHaveCount(0); await expect(page.getByText("第 1 / 3 页")).toBeVisible(); await page.setViewportSize({width:390,height:844}); const filterRows=await page.locator(".job-lane-filters>button").evaluateAll(buttons=>{ const tops=buttons.map(button=>Math.round(button.getBoundingClientRect().top)); return [...new Set(tops)].map(top=>tops.filter(value=>value===top).length); }); expect(filterRows).toEqual([3,2]); await expect(page.locator(".job-lane-filters")).not.toHaveCSS("overflow-x","auto"); }); test("profile collections support multi-select cleanup without deleting videos",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); const cleanups:Array<{video_ids:string[];action:string}>=[]; await page.route("**/api/v1/videos/state/bulk",async route=>{ cleanups.push(route.request().postDataJSON() as {video_ids:string[];action:string}); await route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({updated:1})}); }); await page.goto("/"); await page.locator(".mobile-profile-button").click(); const cases=[ {tab:"收藏",button:"取消收藏",action:"unfavorite"}, {tab:"喜欢",button:"取消喜欢",action:"unlike"}, {tab:"观看记录",button:"清除记录",action:"clear_history"}, ]; for(const item of cases){ await page.locator(".segment-nav").getByRole("button",{name:item.tab,exact:true}).click(); await expect(page.locator(".profile-videos .video-card")).toHaveCount(8); await page.locator(".profile-videos").getByRole("button",{name:"管理",exact:true}).click(); await page.locator(".profile-videos .video-card").first().click(); await expect(page.getByText("已选择 1 部")).toBeVisible(); await page.locator(".profile-selection-bar").getByRole("button",{name:item.button}).click(); await expect(page.getByRole("alertdialog")).toBeVisible(); await page.getByRole("alertdialog").getByRole("button",{name:"继续"}).click(); await expect.poll(()=>cleanups.length).toBe(cases.indexOf(item)+1); expect(cleanups.at(-1)).toEqual({video_ids:["video-0"],action:item.action}); } }); test("custom player exposes Quark-style controls, keyboard seeking and touch fast play",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await page.addInitScript(()=>{ const mediaSources=new WeakMap(); Object.defineProperty(HTMLMediaElement.prototype,"src",{configurable:true,get(){return mediaSources.get(this)||""},set(value:string){mediaSources.set(this,value)}}); Object.defineProperty(HTMLMediaElement.prototype,"load",{configurable:true,value:function(){}}); Object.defineProperty(HTMLMediaElement.prototype,"play",{configurable:true,value:function(this:HTMLMediaElement){Object.defineProperty(this,"paused",{configurable:true,writable:true,value:false});this.dispatchEvent(new Event("play"));this.dispatchEvent(new Event("playing"));return Promise.resolve()}}); Object.defineProperty(HTMLMediaElement.prototype,"pause",{configurable:true,value:function(this:HTMLMediaElement){Object.defineProperty(this,"paused",{configurable:true,writable:true,value:true});this.dispatchEvent(new Event("pause"))}}); }); await mockApi(page); await page.goto("/"); await page.locator(".video-card").first().click(); const video=page.locator("video"); await video.evaluate(element=>{Object.defineProperties(element,{duration:{configurable:true,value:5400},currentTime:{configurable:true,writable:true,value:100},paused:{configurable:true,writable:true,value:true},buffered:{configurable:true,value:{length:2,start:(index:number)=>index?600:0,end:(index:number)=>index?900:300}}});element.dispatchEvent(new Event("loadedmetadata"));element.dispatchEvent(new Event("progress"));element.dispatchEvent(new Event("timeupdate"));element.dispatchEvent(new Event("canplay"))}); await expect(page.locator(".player-progress-buffer>i")).toHaveCount(2); await video.dispatchEvent("waiting"); await expect(page.getByRole("status").filter({hasText:/正在缓冲 \d+%/})).toBeVisible(); await video.dispatchEvent("canplay"); await expect(page.getByRole("slider",{name:"播放进度"})).toBeVisible(); await expect(page.getByRole("button",{name:"快退 10 秒"})).toBeVisible(); await expect(page.getByRole("button",{name:"快进 10 秒"})).toBeVisible(); await page.locator(".player-play").click(); await expect(page.locator(".player-play")).toHaveAttribute("aria-label","暂停"); const beforeKeyboardSeek=await video.evaluate(element=>element.currentTime); await page.keyboard.press("ArrowRight"); await expect(page.getByRole("status").filter({hasText:"快进 5 秒"})).toBeVisible(); expect(await video.evaluate(element=>element.currentTime)).toBe(beforeKeyboardSeek+5); await page.locator(".player-rate>button").click(); await page.getByRole("menuitem",{name:"1.5×"}).click(); expect(await video.evaluate(element=>element.playbackRate)).toBe(1.5); const gestureSurface=page.locator(".player-gesture-surface"); await gestureSurface.dispatchEvent("pointerdown",{pointerType:"touch",pointerId:1,clientX:500,clientY:250,isPrimary:true}); await expect(page.getByRole("status").filter({hasText:"2× 快速播放"})).toBeVisible(); expect(await video.evaluate(element=>element.playbackRate)).toBe(2); await gestureSurface.dispatchEvent("pointerup",{pointerType:"touch",pointerId:1,clientX:500,clientY:250,isPrimary:true}); expect(await video.evaluate(element=>element.playbackRate)).toBe(1.5); await expect(page.getByRole("button",{name:"全屏"})).toBeVisible(); }); test("mobile player contains long curated titles and control intrinsic widths",async({page})=>{ await page.setViewportSize({width:390,height:844}); await mockApi(page); const longTitle="这是一条经过人工整理但长度非常长的视频标题,用来验证播放器标题、顶部控制栏和底部操作控件不会把移动端页面撑出视口"; const longVideo={...videos[0],title:longTitle,display_name:"raw-video-file.mp4"}; await page.route("**/api/v1/home**",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({ video_count:videos.length,updated_at:"2026-07-27T10:00:00Z",recent:{id:"recent",title:"最近新增",total:videos.length,items:[longVideo,...videos.slice(1)]},collections:{total:collections.length,items:collections},categories:[],people:[],unorganized:null, })})); await page.goto("/"); await page.locator(".video-card").first().click(); await expect(page.locator(".player-title h1")).toHaveText(longTitle); const geometry=await page.evaluate(()=>Object.fromEntries([".content",".player-page",".player-stage",".player-controls",".player-control-row",".player-title"].map(selector=>{const node=document.querySelector(selector)!;const box=node.getBoundingClientRect();return [selector,{left:box.left,right:box.right,width:box.width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}]}))); for(const [selector,box] of Object.entries(geometry) as Array<[string,{left:number;right:number;width:number;scrollWidth:number;clientWidth:number}]>){ expect(box.left,`${selector} starts inside viewport`).toBeGreaterThanOrEqual(-1); expect(box.right,`${selector} ends inside viewport`).toBeLessThanOrEqual(391); expect(box.scrollWidth,`${selector} has no horizontal overflow`).toBeLessThanOrEqual(box.clientWidth+1); } expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(391); }); test("player can create a collection and add the current video",async({page})=>{ await page.setViewportSize({width:390,height:844}); await page.addInitScript(()=>{ Object.defineProperty(HTMLMediaElement.prototype,"load",{configurable:true,value:function(){}}); Object.defineProperty(HTMLMediaElement.prototype,"play",{configurable:true,value:function(this:HTMLMediaElement){Object.defineProperty(this,"paused",{configurable:true,writable:true,value:false});this.dispatchEvent(new Event("playing"));return Promise.resolve()}}); }); await mockApi(page); let created:{name:string;video_ids:string[]}|null=null; await page.route("**/api/v1/collections",async route=>{ if(route.request().method()!=="POST")return route.fallback(); created=route.request().postDataJSON() as typeof created; await route.fulfill({status:201,contentType:"application/json",body:JSON.stringify({...collections[0],id:"new-collection",name:created!.name,video_count:1})}); }); await page.goto("/"); await page.locator(".video-card").first().click(); await page.locator("video").dispatchEvent("canplay"); await page.locator(".player-actions").getByRole("button",{name:/示例合集/}).click(); await expect(page.getByRole("dialog",{name:"加入合集"})).toBeVisible(); await page.getByRole("dialog",{name:"加入合集"}).getByLabel("新建合集").fill("稍后观看"); await page.getByRole("dialog",{name:"加入合集"}).getByRole("button",{name:"创建并加入"}).click(); await expect.poll(()=>created).toEqual({name:"稍后观看",video_ids:["video-0"]}); await expect(page.getByRole("dialog",{name:"加入合集"})).toHaveCount(0); }); test("mobile touch taps reach player controls instead of the gesture surface",async({browser})=>{ const context=await browser.newContext({viewport:{width:390,height:844},isMobile:true,hasTouch:true,locale:"zh-CN"}); const page=await context.newPage(); await page.addInitScript(()=>{ // Keep this interaction test independent of Chromium's media decoder and // the mocked API response. Assigning a real src would otherwise emit a // late error event and replace the center controls while Playwright taps. const mediaSources=new WeakMap(); Object.defineProperty(HTMLMediaElement.prototype,"src",{configurable:true,get(){return mediaSources.get(this)||""},set(value:string){mediaSources.set(this,value)}}); Object.defineProperty(HTMLMediaElement.prototype,"load",{configurable:true,value:function(){}}); Object.defineProperty(HTMLMediaElement.prototype,"play",{configurable:true,value:function(this:HTMLMediaElement){Object.defineProperty(this,"paused",{configurable:true,writable:true,value:false});this.dispatchEvent(new Event("playing"));return Promise.resolve()}}); Object.defineProperty(HTMLMediaElement.prototype,"pause",{configurable:true,value:function(this:HTMLMediaElement){Object.defineProperty(this,"paused",{configurable:true,writable:true,value:true});this.dispatchEvent(new Event("pause"))}}); }); await mockApi(page); await page.goto("/"); await page.locator(".video-card").first().tap(); await page.locator("video").evaluate(element=>{Object.defineProperties(element,{duration:{configurable:true,value:5400},currentTime:{configurable:true,writable:true,value:10},paused:{configurable:true,writable:true,value:true}});element.dispatchEvent(new Event("loadedmetadata"));element.dispatchEvent(new Event("canplay"))}); await page.locator(".player-play").tap(); await expect(page.locator(".player-play")).toHaveAttribute("aria-label","暂停"); await page.locator(".player-rate>button").tap(); await page.getByRole("menuitem",{name:"1.25×"}).tap(); expect(await page.locator("video").evaluate(element=>element.playbackRate)).toBe(1.25); const surface=page.locator(".player-gesture-surface"); await surface.dispatchEvent("pointerdown",{pointerType:"touch",pointerId:11,clientX:195,clientY:100,isPrimary:true}); await surface.dispatchEvent("pointerup",{pointerType:"touch",pointerId:11,clientX:195,clientY:100,isPrimary:true}); await page.waitForTimeout(320); await expect(page.locator(".custom-player")).toHaveClass(/controls-hidden/); await surface.dispatchEvent("pointerdown",{pointerType:"touch",pointerId:12,clientX:195,clientY:100,isPrimary:true}); await surface.dispatchEvent("pointerup",{pointerType:"touch",pointerId:12,clientX:195,clientY:100,isPrimary:true}); await page.waitForTimeout(320); await expect(page.locator(".custom-player")).toHaveClass(/controls-visible/); await page.waitForTimeout(500); await expect(page.locator(".custom-player")).toHaveClass(/controls-visible/); await page.waitForTimeout(3700); await expect(page.locator(".custom-player")).toHaveClass(/controls-hidden/); const beforeDouble=await page.locator("video").evaluate(element=>element.currentTime); for(const pointerId of [13,14]){ await surface.dispatchEvent("pointerdown",{pointerType:"touch",pointerId,clientX:320,clientY:100,isPrimary:true}); await surface.dispatchEvent("pointerup",{pointerType:"touch",pointerId,clientX:320,clientY:100,isPrimary:true}); } await expect.poll(()=>page.locator("video").evaluate(element=>element.currentTime)).toBe(beforeDouble+10); const beforeDrag=await page.locator("video").evaluate(element=>element.currentTime); await surface.dispatchEvent("pointerdown",{pointerType:"touch",pointerId:15,clientX:80,clientY:100,isPrimary:true}); await surface.dispatchEvent("pointermove",{pointerType:"touch",pointerId:15,clientX:220,clientY:104,isPrimary:true}); await surface.dispatchEvent("pointerup",{pointerType:"touch",pointerId:15,clientX:220,clientY:104,isPrimary:true}); await expect.poll(()=>page.locator("video").evaluate(element=>element.currentTime)).toBeGreaterThan(beforeDrag); const beforeVertical=await page.locator("video").evaluate(element=>element.currentTime); await surface.dispatchEvent("pointerdown",{pointerType:"touch",pointerId:16,clientX:190,clientY:60,isPrimary:true}); await surface.dispatchEvent("pointermove",{pointerType:"touch",pointerId:16,clientX:194,clientY:150,isPrimary:true}); await surface.dispatchEvent("pointerup",{pointerType:"touch",pointerId:16,clientX:194,clientY:150,isPrimary:true}); expect(await page.locator("video").evaluate(element=>element.currentTime)).toBe(beforeVertical); // fnOS and other embedded WebViews may synthesize a mouse pointer stream // for a touch. The hover move reveals controls immediately; the subsequent // click must not hide them again when the 280 ms tap timer fires. await page.waitForTimeout(4100); await expect(page.locator(".custom-player")).toHaveClass(/controls-hidden/); const surfaceBox=await surface.boundingBox(); expect(surfaceBox).not.toBeNull(); const mouseX=surfaceBox!.x+surfaceBox!.width/2; const mouseY=surfaceBox!.y+surfaceBox!.height/2; await page.mouse.move(mouseX,mouseY); await page.mouse.click(mouseX,mouseY); await page.waitForTimeout(400); await expect(page.locator(".custom-player")).toHaveClass(/controls-visible/); await page.getByRole("button",{name:"视频时间点"}).click(); await expect(page.locator(".player-marker-drawer")).toBeVisible(); await context.close(); }); test("model health is shown on its component and shared runtime is summarized once",async({page})=>{ await page.setViewportSize({width:1024,height:800}); await mockApi(page); await page.route("**/api/v1/models?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({ visual_ready:true,ocr_ready:true,faces_ready:false,audio_ready:true,pending_videos:0,hf_endpoint:"https://huggingface.co", pip:{index_url:"https://pypi.tuna.tsinghua.edu.cn/simple",pytorch_index_url:""},proxy:{enabled:false,url:"",username:"",has_password:false}, component_versions:{visual:"1",ocr:"1",audio:"1"},component_sizes:{visual:100,ocr:100,faces:0,audio:100},runtime_dependencies:{visual:{state:"ready"},ocr:{state:"ready"},faces:{state:"ready"},audio:{state:"ready"}}, runtime_environment:{state:"ready",size_bytes:1739461755,components:["visual","ocr","faces","audio"]},component_health:{faces:{state:"damaged",error:"人物模型文件损坏,请重新下载"}}, accelerator:{selected:"AUTO",components:{},devices:[],cpu_threads:2},sources:{faces:{},visual_image:"",visual_text:"",ocr:"",audio:""}, })})); await page.goto("/"); await openSettings(page,1024); const faces=page.locator(".model-component").filter({hasText:"人物识别"}); await expect(faces).toContainText("人物模型文件损坏,请重新下载"); await expect(page.locator(".shared-runtime")).toHaveCount(1); await expect(page.locator(".shared-runtime")).toContainText("1.62 GB · 4 个组件已就绪"); const componentText=await page.locator(".model-component").allTextContents(); expect(componentText.some(text=>text.includes("1.62 GB"))).toBe(false); }); test("writes a complete light and dark UI audit for desktop and mobile",async({page})=>{ // This emits more than forty full-page screenshots. Keep enough headroom for // Chromium on the 4 GB release runner when it is reclaiming visual contexts. test.setTimeout(600_000); await mockApi(page); const auditJobs=[ {id:"audit-job-1",kind:"index_video",status:"running",progress:.46,message:"正在解析视频资料",attempts:1,created_at:"2026-07-28T10:00:00Z"}, {id:"audit-job-2",kind:"transcribe_audio",status:"completed",progress:1,message:"音频识别完成",attempts:1,created_at:"2026-07-28T09:00:00Z"}, {id:"audit-job-3",kind:"scan_source",status:"failed",progress:.2,error:"媒体来源暂时不可用",attempts:3,created_at:"2026-07-28T08:00:00Z"}, ]; await page.route("**/api/v1/jobs?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({items:auditJobs,page:1,page_size:10,total:23,pages:3})})); const output="../dist/ui-audit-0.5.45"; await mkdir(output,{recursive:true}); const destinations:AuditPage[]=["home","search","library","series","uploads","tags","actors","profile","settings","player"]; for(const viewport of [{name:"desktop",width:1440,height:900},{name:"mobile",width:390,height:844}] as const){ await page.setViewportSize({width:viewport.width,height:viewport.height}); for(const theme of ["light","dark"] as const){ await page.emulateMedia({colorScheme:theme}); for(const destination of destinations){ await openAuditPage(page,destination,viewport.width); await page.evaluate(()=>document.fonts.ready); await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth-innerWidth)).toBeLessThanOrEqual(1); await page.screenshot({path:`${output}/${theme}-${viewport.name}-${destination}.png`,animations:"disabled"}); } await openAuditPage(page,"profile",viewport.width); await page.locator(".profile-page>.segment-nav").getByRole("button",{name:"个人资料"}).click(); await expect(page.getByRole("heading",{name:"播放与隐私"})).toBeVisible(); await page.screenshot({path:`${output}/${theme}-${viewport.name}-profile-account.png`,animations:"disabled"}); await openAuditPage(page,"profile",viewport.width); await page.locator(".profile-page>.segment-nav").getByRole("button",{name:"收藏",exact:true}).click(); await page.locator(".profile-videos").getByRole("button",{name:"管理"}).click(); await page.locator(".profile-videos .video-card").first().click(); await page.screenshot({path:`${output}/${theme}-${viewport.name}-profile-manage.png`,animations:"disabled"}); await openAuditPage(page,"settings",viewport.width); await page.locator(".settings-segments").getByRole("button",{name:"任务与偏好"}).click(); await expect(page.locator(".mini-job")).toHaveCount(3); await page.screenshot({path:`${output}/${theme}-${viewport.name}-settings-tasks.png`,animations:"disabled"}); if(viewport.width<=720){ await openAuditPage(page,"home",viewport.width); await page.getByRole("button",{name:"更多"}).click(); await expect(page.locator(".mobile-more-menu")).toBeVisible(); await page.screenshot({path:`${output}/${theme}-${viewport.name}-more.png`,animations:"disabled"}); } } } }); test("failed upload status is accessible, capped at 99+ and respects reduced motion", async ({ page }) => { await page.setViewportSize({ width: 320, height: 780 }); await page.emulateMedia({ reducedMotion: "reduce" }); await mockApi(page); const failed = Array.from({ length: 105 }, (_, index) => ({ id: `failed-${index}`, filename: `failed-${index}.mp4`, source_id: source.id, status: "failed", progress: .2, bytes_received: 20, size_bytes: 100, error: "网络中断", relative_path: "", chunk_size: 10, total_chunks: 10, received_chunks: [0, 1] })); await page.route("**/api/v1/uploads?*", route => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(failed) })); await page.goto("/"); const status = page.locator(".sidebar nav > .upload-status"); await expect(status).toHaveClass(/failed/); await expect(status.locator("b")).toHaveText("99+"); await expect(status).toHaveAttribute("aria-label", /105 个任务/); const animation = await status.locator("svg").evaluate(element => getComputedStyle(element).animationName); expect(animation).toBe("none"); await page.getByRole("button",{name:"更多"}).click(); expect(await page.locator(".mobile-more-menu").evaluate(element=>getComputedStyle(element).animationName)).toBe("none"); await page.locator(".sheet-close").click(); await expect(page.locator(".mobile-more-menu")).toBeHidden(); await status.click(); await expect(page.getByRole("heading", { name: "传输中心" })).toBeVisible(); }); test("critical pages stay horizontally contained across supported viewports",async({page})=>{ await mockApi(page); const noisy="dc5d56e813283316e7b37705e400bf4a_logo.mp4.8951e586f4d7aebf41f43fac535449c8.mp4"; await page.route("**/api/v1/uploads?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({items:[{id:"viewport-upload",filename:noisy,source_id:source.id,status:"transferring",progress:.45,bytes_received:45,size_bytes:100,message:"传输到目标库 45%",relative_path:"",chunk_size:10,total_chunks:10,received_chunks:[0,1,2,3]}],status_items:[],active_count:1,failed_count:0,page:1,page_size:10,total:1,pages:1})})); for(const width of [320,390,412,768,1024,1440]){ await page.setViewportSize({width,height:Math.min(900,Math.max(780,width))}); for(const destination of ["home","uploads","player"] as const){ await openAuditPage(page,destination,width); const geometry=await page.evaluate(()=>({document:document.documentElement.scrollWidth,viewport:innerWidth,content:document.querySelector(".content")?.scrollWidth||0,contentClient:document.querySelector(".content")?.clientWidth||0})); expect(geometry.document,`${destination} document at ${width}px`).toBeLessThanOrEqual(geometry.viewport+1); if(width<=720)expect(geometry.content,`${destination} content at ${width}px`).toBeLessThanOrEqual(geometry.contentClient+1); } } });