2144 lines
152 KiB
TypeScript
2144 lines
152 KiB
TypeScript
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:05–1:08");
|
||
expect(searchBodies.at(-1)).toMatchObject({text:"海浪声音",recognition_types:["audio"]});
|
||
|
||
await page.locator('input[type="file"][accept="image/*"]').setInputFiles({name:"scene.png",mimeType:"image/png",buffer:Buffer.from("image")});
|
||
await expect(recognition.getByRole("button",{name:"画面语义"})).toHaveAttribute("aria-pressed","true");
|
||
await expect(recognition.getByRole("button",{name:"音频识别"})).toHaveAttribute("aria-pressed","false");
|
||
await expect.poll(()=>searchBodies.filter(body=>body.image_id==="query-image").at(-1)).toMatchObject({image_id:"query-image",recognition_types:["visual"]});
|
||
});
|
||
|
||
test("desktop primary text does not regress to undersized labels",async({page})=>{
|
||
await page.setViewportSize({width:1440,height:900});
|
||
await mockApi(page);
|
||
await page.goto("/");
|
||
const undersized=await page.locator(".video-card .video-info h3,.video-card .video-info p,.video-card .duration,.chip-row button,.sidebar nav button").evaluateAll(elements=>elements.map(element=>({text:element.textContent?.trim(),size:Number.parseFloat(getComputedStyle(element).fontSize)})).filter(item=>item.size<11));
|
||
expect(undersized).toEqual([]);
|
||
});
|
||
|
||
test("thumbnails expose loading, missing and error placeholders without broken images",async({page})=>{
|
||
await page.setViewportSize({width:390,height:844});
|
||
await mockApi(page);
|
||
const thumbnailVideos=videos.slice(0,3).map((video,index)=>({...video,thumbnail_url:index===2?undefined:`/api/v1/frames/${index===0?"slow":"broken"}/thumbnail`}));
|
||
await page.route("**/api/v1/videos?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(thumbnailVideos)}));
|
||
await page.route("**/api/v1/home?*",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify({video_count:thumbnailVideos.length,updated_at:"2026-07-27T10:00:00Z",recent:{id:"recent",title:"最近新增",total:thumbnailVideos.length,items:thumbnailVideos},collections:{total:0,items:[]},categories:[],people:[],unorganized:null})}));
|
||
const pixel=Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=","base64");
|
||
await page.route("**/api/v1/frames/**",async route=>{
|
||
if(route.request().url().includes("/slow/")){
|
||
await new Promise(resolve=>setTimeout(resolve,350));
|
||
await route.fulfill({status:200,contentType:"image/png",body:pixel});
|
||
}else await route.fulfill({status:404,contentType:"application/json",body:JSON.stringify({detail:"missing"})});
|
||
});
|
||
await page.goto("/",{waitUntil:"domcontentloaded"});
|
||
await expect(page.locator(".video-card").nth(0).getByText("封面加载中")).toBeVisible();
|
||
await expect(page.locator(".video-card").nth(1).getByText("封面加载失败")).toBeVisible();
|
||
await expect(page.locator(".video-card").nth(2).getByText("暂无封面")).toBeVisible();
|
||
await expect(page.locator(".video-card").nth(0).locator(".thumb-placeholder")).toHaveCount(0);
|
||
await expect(page.locator(".video-card img[src]")).toHaveCount(2);
|
||
});
|
||
|
||
test("player deletion chooses source handling and requires a second confirmation",async({page})=>{
|
||
await page.setViewportSize({width:1024,height:800});
|
||
await mockApi(page);
|
||
await page.goto("/");
|
||
await page.locator(".video-card").first().click();
|
||
await expect(page.getByRole("button",{name:"删除视频"})).toBeVisible();
|
||
await page.getByRole("button",{name:"删除视频"}).click();
|
||
const choice=page.getByRole("alertdialog");
|
||
await expect(choice).toContainText("只从媒体库移除");
|
||
await choice.getByRole("button",{name:"仅移出媒体库"}).click();
|
||
const dialog=page.getByRole("alertdialog");
|
||
await expect(dialog).toContainText("源文件保持不变");
|
||
const deletion=page.waitForRequest(request=>request.method()==="DELETE"&&request.url().includes("/api/v1/videos/video-0"));
|
||
await dialog.getByRole("button",{name:"继续"}).click();
|
||
expect((await deletion).url()).toContain("delete_source=false");
|
||
await expect(page.locator(".toast-host")).toContainText("视频已从媒体库移除,索引与缓存已清理");
|
||
});
|
||
|
||
test("transfer center exposes upload and system aria2 download flows",async({page})=>{
|
||
await page.setViewportSize({width:1024,height:800});
|
||
await mockApi(page);
|
||
const activeDownload={id:"download-1",kind:"url",display_name:"movie.mp4",source_id:source.id,relative_path:"downloads",status:"downloading",progress:.36,total_bytes:1000,completed_bytes:360,download_speed:20,error:null,files:[],created_at:"2026-07-31T10:00:00Z"};
|
||
await page.route("**/api/v1/downloads",route=>route.fulfill({status:200,contentType:"application/json",body:JSON.stringify([activeDownload])}));
|
||
await page.goto("/");
|
||
await expect(page.locator(".sidebar nav > .upload-status")).toHaveAttribute("aria-label",/movie\.mp4 · 后台下载 36%/);
|
||
await expect(page.locator(".sidebar nav > .upload-status b")).toHaveText("1");
|
||
await page.locator(".sidebar nav > .upload-status").click();
|
||
await expect(page.getByRole("heading",{name:"传输中心"})).toBeVisible();
|
||
await page.locator(".transfer-tabs").getByRole("button",{name:/后台下载/}).click();
|
||
await expect(page.locator(".download-list")).toContainText("movie.mp4");
|
||
await page.getByRole("button",{name:"新建下载"}).click();
|
||
await expect(page.getByRole("heading",{name:"新建后台下载"})).toBeVisible();
|
||
await expect(page.getByPlaceholder("https://…、ftp://… 或 magnet:?…")).toBeVisible();
|
||
await expect(page.getByRole("button",{name:"选择 .torrent 文件"})).toBeVisible();
|
||
});
|
||
|
||
test("continue watching covers use gateway media auth and loading indicators stay centered",async({page})=>{
|
||
await page.setViewportSize({width:390,height:844});
|
||
await page.addInitScript(()=>{window.__IMAGEFIND_BASE__="/app/imagefind/"});
|
||
await mockApi(page,"gateway");
|
||
const continued={...videos[0],thumbnail_url:"/api/v1/frames/continue-frame/thumbnail"};
|
||
await page.route("**/api/v1/videos?*",route=>{
|
||
const url=new URL(route.request().url());
|
||
const body=url.searchParams.get("continue_only")==="true"?[continued]:[];
|
||
return route.fulfill({status:200,contentType:"application/json",body:JSON.stringify(body)});
|
||
});
|
||
let releaseThumbnail!:()=>void;
|
||
const thumbnailGate=new Promise<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);
|
||
}
|
||
}
|
||
});
|