Files
imagefind/frontend/tests/ui.spec.ts
T

2144 lines
152 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Record<string,unknown>>=[];
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<typeof preferences>) };
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<typeof speechConfig>};
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<typeof webdavConfig>};
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<Record<AuditPage,string>>={home:"最近新增",search:"搜索你的媒体库",library:"媒体资料库",series:"合集",uploads:"传输中心",tags:"分类与标签",actors:"演员与人物",profile:"管理员",settings:"设置"};
const auditMenuNames:Partial<Record<AuditPage,string>>={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<Exclude<AuditPage,"home"|"search"|"profile"|"player">,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.bottom<duration.top});
expect(contained).toBe(true);
expect(await page.evaluate(()=>document.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<HTMLElement>(".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<HTMLElement>(".icon-button")]
.filter(button => {
const style = getComputedStyle(button);
return style.display !== "none" && button.getBoundingClientRect().width > 0;
})
.map(button => {
const icon = button.querySelector<SVGElement>("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<HTMLElement>(".mobile-more-icon")||item.querySelector<SVGElement>("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<Record<string,unknown>>=[];
const chunkIndexes:number[]=[];
let firstChunkSeen!:()=>void;
let releaseFirstChunk!:()=>void;
const firstChunk=new Promise<void>(resolve=>{firstChunkSeen=resolve});
const chunkGate=new Promise<void>(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<string,unknown>;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<HTMLMediaElement,string>();
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<Record<string,unknown>>=[];
await page.route("**/api/v1/webdav/config",async route=>{
if(route.request().method()==="PATCH")updates.push(route.request().postDataJSON() as Record<string,unknown>);
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<HTMLElement>(".mobile-more-backdrop")!.getBoundingClientRect();
const sheet=document.querySelector<HTMLElement>(".mobile-more-menu")!;
const bottom=document.querySelector<HTMLElement>(".sidebar")!;
const grid=document.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".app-shell")!;const content=document.querySelector<HTMLElement>(".content")!;const topbar=document.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".mobile-more-menu")!).backgroundColor,tile:getComputedStyle(document.querySelector<HTMLElement>(".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<string,any>={
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<string,any>={
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<HTMLElement>(".content")!;const list=document.querySelector<HTMLElement>(".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<Record<string,unknown>>=[];
await page.route("**/api/v1/uploads",async route=>{
if(route.request().method()!=="POST"){await route.fallback();return}
const request=route.request().postDataJSON() as Record<string,unknown>;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<Record<string,unknown>>=[];
let releaseInitialTrash!:()=>void;
const initialTrashGate=new Promise<void>(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<void>(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:051: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<void>(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<void>(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<HTMLMediaElement,string>();
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<HTMLElement>(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<HTMLMediaElement,string>();
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<HTMLElement>(".content")?.scrollWidth||0,contentClient:document.querySelector<HTMLElement>(".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);
}
}
});