feat: add ImageFind application and release pipelines

This commit is contained in:
2026-08-11 18:02:40 +08:00
commit 16239d7525
270 changed files with 59163 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
import { expect, Page, test } from "@playwright/test";
const sourceName=process.env.IMAGEFIND_E2E_SOURCE_NAME;
async function appApi<T>(page:Page,path:string,options:RequestInit={}):Promise<T>{
return page.evaluate(async({path,options})=>{
const base=(window.__IMAGEFIND_BASE__||"/").replace(/\/$/,"");
const headers=new Headers(options.headers);const csrf=sessionStorage.getItem("imagefind:csrf");
if(csrf)headers.set("X-CSRF-Token",csrf);
if(typeof options.body==="string")headers.set("Content-Type","application/json");
const response=await fetch(`${base}/api/v1${path}`,{...options,headers,credentials:"same-origin"});
if(!response.ok)throw new Error(`${response.status} ${await response.text()}`);
return response.status===204?null:response.json();
},{path,options}) as Promise<T>;
}
test("fnOS deployment renders the app shell and system-history navigation",async({page})=>{
await page.setViewportSize({width:390,height:844});
await page.goto("");
await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible();
const homeLayout=await page.evaluate(()=>{const content=document.querySelector<HTMLElement>(".content")!;const shell=document.querySelector<HTMLElement>(".app-shell")!;return {access:document.documentElement.dataset.imagefindAccess,topbarTop:Math.round(document.querySelector<HTMLElement>(".topbar")!.getBoundingClientRect().top),contentOverflow:getComputedStyle(content).overflowY,shellOverflow:getComputedStyle(shell).overflow,documentHeight:document.documentElement.scrollHeight,viewportHeight:innerHeight}});
expect(["direct","gateway"]).toContain(homeLayout.access);
expect(homeLayout).toMatchObject({topbarTop:0,contentOverflow:"auto",shellOverflow:"hidden"});
expect(homeLayout.documentHeight).toBeLessThanOrEqual(homeLayout.viewportHeight+1);
await expect(page.locator(".sidebar nav > button:visible")).toHaveCount(5);
await page.getByRole("button",{name:"更多"}).click();
await expect(page.locator(".mobile-more-menu").getByText("退出登录",{exact:true})).toHaveCount(homeLayout.access==="gateway"?0:1);
await expect(page.getByText("返回飞牛桌面",{exact:true})).toHaveCount(0);
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"设置"}).click();
await expect(page.getByRole("heading",{name:"设置"})).toBeVisible();
await expect(page.locator(".topbar")).toBeHidden();
await expect(page.locator(".sidebar")).toBeHidden();
await expect(page.locator(".content")).not.toHaveClass(/sheet-locked/);
await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollHeight-element.clientHeight)).toBeGreaterThan(320);
await page.locator(".content").evaluate(element=>element.scrollTo({top:320,behavior:"instant"}));
await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollTop)).toBeGreaterThan(100);
await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
await page.goBack();
await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible();
});
test("live transfer, profile, resources and media surfaces remain usable",async({page})=>{
await page.setViewportSize({width:390,height:844});
await page.goto("");
const status=await appApi<{configured:boolean;version:string;access_mode:string}>(page,"/status");
expect(status).toMatchObject({configured:true,version:"0.5.45"});
await page.getByRole("button",{name:"更多"}).click();
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"上传中心"}).click();
await expect(page.getByRole("heading",{name:"传输中心"})).toBeVisible();
await expect(page.locator(".transfer-tabs>button")).toHaveCount(2);
await page.locator(".transfer-tabs").getByRole("button",{name:/后台下载/}).click();
const aria2=await appApi<{available:boolean;running:boolean}>(page,"/downloads/runtime");
expect(typeof aria2.available).toBe("boolean");
await expect(page.locator(".download-list")).toBeVisible();
await page.goBack();
await page.locator(".mobile-profile-button").click();
await expect(page.getByRole("button",{name:"个人资料"})).toBeVisible();
await expect(page.locator(".profile-stats article")).toHaveCount(4);
await page.getByRole("button",{name:"更多"}).click();
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"设置"}).click();
await page.getByRole("button",{name:"任务与偏好"}).click();
await expect(page.locator(".resource-panel")).toContainText("前台访问优先");
await expect(page.locator(".resource-lanes article")).toHaveCount(4);
const resources=await appApi<{profile:string;policy:{cpu_pause_percent:number;cpu_resume_percent:number}}>(page,"/system/resources");
expect(["conservative","balanced","turbo"]).toContain(resources.profile);
expect(resources.policy.cpu_resume_percent).toBeLessThan(resources.policy.cpu_pause_percent);
await page.goBack();
await page.locator(".nav-item-1").click();
const cards=page.locator(".video-card");
if(await cards.count()){
await cards.first().click();
await expect(page.locator(".player-stage video")).toBeVisible();
await expect(page.getByRole("button",{name:"删除视频"})).toBeVisible();
const source=await page.locator(".player-stage video").getAttribute("src");
expect(source).toContain("/api/v1/videos/");
}
});
for(const width of [320,390])test(`live mobile layout has no horizontal overflow at ${width}px`,async({page})=>{
await page.setViewportSize({width,height:780});
await page.goto("");
const geometry=await page.evaluate(()=>({viewport:innerWidth,document:document.documentElement.scrollWidth,content:document.querySelector<HTMLElement>(".content")!.scrollWidth}));
expect(geometry.document).toBeLessThanOrEqual(geometry.viewport+1);
expect(geometry.content).toBeLessThanOrEqual(geometry.viewport+1);
await expect(page.locator(".sidebar nav > button:visible")).toHaveCount(5);
});
test("fnOS isolated source supports upload and test-owned cleanup",async({page})=>{
test.skip(!sourceName,"设置 IMAGEFIND_E2E_SOURCE_NAME 后才运行写入测试");
await page.setViewportSize({width:390,height:844});
await page.goto("");
const filename=`E2E-${Date.now()}.mp4`;
let sourceId="";
try{
await page.locator(".sidebar nav > .upload-status").click();
const sourceSelect=page.getByLabel("目标媒体库");
const option=sourceSelect.locator("option").filter({hasText:sourceName!});
await expect(option).toHaveCount(1);
sourceId=await option.getAttribute("value")||"";
await sourceSelect.selectOption(sourceId);
await page.locator('.drop-zone input[type="file"]').setInputFiles({name:filename,mimeType:"video/mp4",buffer:Buffer.from("imagefind isolated e2e fixture")});
await page.getByRole("button",{name:"开始上传"}).click();
await expect(page.getByText(/已进入后台传输队列|1 个文件已排队/)).toBeVisible();
await page.waitForTimeout(1500);
const uploads=await appApi<Array<{id:string;filename:string}>>(page,"/uploads?limit=100");
expect(uploads.some(item=>item.filename===filename)).toBe(true);
}finally{
if(sourceId){
const videos=await appApi<Array<{source_id:string;source_key:string;display_name:string}>>(page,"/videos?limit=500").catch(()=>[]);
const owned=videos.filter(item=>item.source_id===sourceId&&(item.display_name===filename||item.source_key.endsWith(`/${filename}`)||item.source_key===filename));
if(owned.length)await appApi(page,"/files/trash",{method:"POST",body:JSON.stringify({source_id:sourceId,keys:owned.map(item=>item.source_key)})}).catch(()=>null);
const trash=await appApi<Array<{id:string;display_name:string}>>(page,`/trash?source_id=${encodeURIComponent(sourceId)}`).catch(()=>[]);
for(const item of trash.filter(value=>value.display_name===filename))await appApi(page,`/trash/${item.id}`,{method:"DELETE"}).catch(()=>null);
}
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
{
"viewport": {
"width": 1440,
"height": 900
},
"document": {
"width": 1440,
"height": 1464
},
"topbar": {
"x": 0,
"y": 0,
"width": 1440,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 1216,
"height": 1464
},
"page": {
"x": 224,
"y": 64,
"width": 1216,
"height": 1400
},
"videoGrid": {
"x": 256,
"y": 259,
"width": 1152,
"height": 220
},
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

@@ -0,0 +1,42 @@
{
"viewport": {
"width": 1440,
"height": 900
},
"document": {
"width": 1440,
"height": 1094
},
"topbar": {
"x": 0,
"y": 0,
"width": 1440,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 1216,
"height": 1094
},
"page": {
"x": 224,
"y": 64,
"width": 1216,
"height": 1030
},
"videoGrid": null,
"tagGroups": {
"x": 256,
"y": 198,
"width": 1152,
"height": 236
},
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 394
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,42 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 390,
"height": 58
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 58,
"width": 390,
"height": 1210
},
"videoGrid": {
"x": 12,
"y": 268,
"width": 378,
"height": 175
},
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 298
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,47 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 390,
"height": 58
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 58,
"width": 390,
"height": 1212
},
"videoGrid": {
"x": 12,
"y": 270,
"width": 378,
"height": 175
},
"tagGroups": null,
"mobileMore": {
"x": 0,
"y": 508,
"width": 390,
"height": 336
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -0,0 +1,47 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 390,
"height": 58
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 58,
"width": 390,
"height": 1210
},
"videoGrid": {
"x": 12,
"y": 268,
"width": 378,
"height": 175
},
"tagGroups": null,
"mobileMore": {
"x": 0,
"y": 508,
"width": 390,
"height": 336
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -0,0 +1,32 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": null,
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 1203
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 96,
"width": 390,
"height": 728
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 341
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,42 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 1297
},
"videoGrid": null,
"tagGroups": {
"x": 12,
"y": 153,
"width": 366,
"height": 369
},
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 579
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 1024,
"height": 900
},
"document": {
"width": 1024,
"height": 2198
},
"topbar": {
"x": 0,
"y": 0,
"width": 1024,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 800,
"height": 2198
},
"page": {
"x": 224,
"y": 64,
"width": 800,
"height": 2134
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 1440,
"height": 900
},
"document": {
"width": 1440,
"height": 2000
},
"topbar": {
"x": 0,
"y": 0,
"width": 1440,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 1216,
"height": 2000
},
"page": {
"x": 224,
"y": 64,
"width": 1216,
"height": 1936
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 320,
"height": 780
},
"document": {
"width": 320,
"height": 780
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 320,
"height": 780
},
"page": {
"x": 0,
"y": 0,
"width": 320,
"height": 2967
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 780
},
"document": {
"width": 390,
"height": 780
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 780
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 2908
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 768,
"height": 900
},
"document": {
"width": 768,
"height": 2522
},
"topbar": {
"x": 0,
"y": 0,
"width": 768,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 544,
"height": 2522
},
"page": {
"x": 224,
"y": 64,
"width": 544,
"height": 2458
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 1024,
"height": 800
},
"document": {
"width": 1024,
"height": 2049
},
"topbar": {
"x": 0,
"y": 0,
"width": 1024,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 736
},
"content": {
"x": 224,
"y": 0,
"width": 800,
"height": 2049
},
"page": {
"x": 224,
"y": 64,
"width": 800,
"height": 1985
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB