From ba33c35dd849473a653e57449d33dfbe9952fb5e Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 4 Apr 2026 19:38:36 +0800 Subject: [PATCH] add test --- web/playwright-test.config.ts | 12 + web/test/e2e/v018-test.spec.ts | 411 +++++++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 web/playwright-test.config.ts create mode 100644 web/test/e2e/v018-test.spec.ts diff --git a/web/playwright-test.config.ts b/web/playwright-test.config.ts new file mode 100644 index 0000000..c095369 --- /dev/null +++ b/web/playwright-test.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './test/e2e', + timeout: 30000, + retries: 0, + use: { + baseURL: 'https://airflow-studio.test.airlabs.art', + headless: true, + screenshot: 'only-on-failure', + }, +}); diff --git a/web/test/e2e/v018-test.spec.ts b/web/test/e2e/v018-test.spec.ts new file mode 100644 index 0000000..6c48aa5 --- /dev/null +++ b/web/test/e2e/v018-test.spec.ts @@ -0,0 +1,411 @@ +/** + * v0.18.0 E2E Tests — run against test environment + * Tests: asset search, asset library, generation page interactions + */ +import { test, expect, Page } from '@playwright/test'; + +const BASE_URL = 'https://airflow-studio.test.airlabs.art'; +const API_URL = 'https://airflow-studio-api.test.airlabs.art'; +const USERNAME = 'tudou'; +const PASSWORD = 'seaislee'; + +let accessToken = ''; + +// Login and get token +async function login(page: Page) { + const resp = await page.request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + expect(resp.ok()).toBeTruthy(); + const body = await resp.json(); + accessToken = body.tokens.access; + + // Set tokens in localStorage and navigate + await page.goto(BASE_URL); + await page.evaluate(({ access, refresh }) => { + localStorage.setItem('access_token', access); + localStorage.setItem('refresh_token', refresh); + }, { access: body.tokens.access, refresh: body.tokens.refresh }); + await page.goto(`${BASE_URL}/app`); + await page.waitForTimeout(2000); +} + +// ─── API Tests ─── + +test.describe('Backend API Tests', () => { + test('asset search returns individual assets (not groups)', async ({ request }) => { + // Login + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + // Search for assets + const searchResp = await request.get(`${API_URL}/api/v1/assets/search?q=test`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + }); + expect(searchResp.ok()).toBeTruthy(); + const data = await searchResp.json(); + + // Should return results array + expect(data).toHaveProperty('results'); + expect(Array.isArray(data.results)).toBeTruthy(); + + // Each result should have individual asset fields (not group fields) + if (data.results.length > 0) { + const asset = data.results[0]; + expect(asset).toHaveProperty('id'); + expect(asset).toHaveProperty('name'); + expect(asset).toHaveProperty('url'); + expect(asset).toHaveProperty('asset_type'); + expect(asset).toHaveProperty('group_name'); + expect(asset).toHaveProperty('thumbnail_url'); + expect(asset).toHaveProperty('duration'); + // Should NOT have group-level fields + expect(asset).not.toHaveProperty('asset_count'); + expect(asset).not.toHaveProperty('remote_group_id'); + } + }); + + test('asset search only returns active assets', async ({ request }) => { + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + const searchResp = await request.get(`${API_URL}/api/v1/assets/search?q=a`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + }); + const data = await searchResp.json(); + + // All returned assets should be active + for (const asset of data.results) { + // Search API doesn't return status, but only queries active ones + expect(asset).toHaveProperty('id'); + } + }); + + test('search query is truncated at 100 chars', async ({ request }) => { + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + const longQuery = 'a'.repeat(200); + const searchResp = await request.get(`${API_URL}/api/v1/assets/search?q=${longQuery}`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + }); + // Should not crash + expect(searchResp.ok()).toBeTruthy(); + }); + + test('create asset group without file', async ({ request }) => { + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + const formData = new FormData(); + formData.append('name', `test-e2e-${Date.now()}`); + + // Note: multipart/form-data without file + const createResp = await request.post(`${API_URL}/api/v1/assets/groups`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + multipart: { name: `test-e2e-${Date.now()}` }, + }); + expect(createResp.ok()).toBeTruthy(); + const group = await createResp.json(); + expect(group).toHaveProperty('id'); + expect(group.asset_count).toBe(0); + expect(group.thumbnail_url).toBe(''); + }); + + test('delete asset endpoint works', async ({ request }) => { + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + // Get groups to find an asset to test with + const groupsResp = await request.get(`${API_URL}/api/v1/assets/groups`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + }); + const groups = await groupsResp.json(); + + // Find a group with assets + for (const group of groups.results) { + if (group.asset_count > 0) { + const detailResp = await request.get(`${API_URL}/api/v1/assets/groups/${group.id}`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + }); + const detail = await detailResp.json(); + + // Verify assets have asset_type and thumbnail_url fields + if (detail.assets && detail.assets.length > 0) { + const asset = detail.assets[0]; + expect(asset).toHaveProperty('asset_type'); + expect(asset).toHaveProperty('thumbnail_url'); + expect(asset).toHaveProperty('duration'); + } + break; + } + } + }); + + test('HEIC format accepted in upload', async ({ request }) => { + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + // Upload a fake HEIC file (just test the format check, not actual processing) + const uploadResp = await request.post(`${API_URL}/api/v1/media/upload`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + multipart: { + file: { + name: 'test.heic', + mimeType: 'image/heic', + buffer: Buffer.from('fake heic content'), + }, + }, + }); + // Should not reject with "不支持的文件格式" + // It may fail for other reasons (invalid image), but format should be accepted + const status = uploadResp.status(); + if (status === 400) { + const body = await uploadResp.json(); + expect(body.error).not.toContain('不支持的文件格式'); + } + }); + + test('asset://local- format accepted in generation', async ({ request }) => { + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + // Try to generate with a non-existent asset://local- reference + const genResp = await request.post(`${API_URL}/api/v1/video/generate`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + data: { + prompt: 'test', + mode: 'universal', + model: 'seedance_2.0', + aspect_ratio: '16:9', + duration: 5, + references: [ + { url: 'asset://local-99999', type: 'image', role: 'reference_image', label: 'test' }, + ], + }, + }); + // Should return 400 with friendly error (asset not found), not 500 + expect(genResp.status()).toBe(400); + const body = await genResp.json(); + expect(body.error).toBe('asset_not_found'); + }); + + test('asset://group- format still works (backward compat)', async ({ request }) => { + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + // Try old format with non-existent group + const genResp = await request.post(`${API_URL}/api/v1/video/generate`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + data: { + prompt: 'test', + mode: 'universal', + model: 'seedance_2.0', + aspect_ratio: '16:9', + duration: 5, + references: [ + { url: 'asset://group-99999', type: 'image', role: 'reference_image', label: 'test' }, + ], + }, + }); + // Should return 400 (not ready), not 500 + expect(genResp.status()).toBe(400); + const body = await genResp.json(); + expect(body.error).toBe('asset_not_ready'); + }); + + test('blob: URL rejected by backend', async ({ request }) => { + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + const genResp = await request.post(`${API_URL}/api/v1/video/generate`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + data: { + prompt: 'test blob', + mode: 'universal', + model: 'seedance_2.0', + aspect_ratio: '16:9', + duration: 5, + references: [ + { url: 'blob:http://localhost/fake', type: 'image', role: 'reference_image', label: 'test' }, + ], + }, + }); + expect(genResp.status()).toBe(400); + const body = await genResp.json(); + expect(body.error).toBe('upload_failed'); + }); + + test('task detail returns thumbnail_url field', async ({ request }) => { + const loginResp = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + + const tasksResp = await request.get(`${API_URL}/api/v1/video/tasks?page_size=1`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + }); + expect(tasksResp.ok()).toBeTruthy(); + const data = await tasksResp.json(); + + if (data.results.length > 0) { + const task = data.results[0]; + expect(task).toHaveProperty('thumbnail_url'); + expect(task).toHaveProperty('result_url'); + } + }); +}); + +// ─── Frontend Page Tests ─── + +test.describe('Frontend Page Tests', () => { + test('login page loads', async ({ page }) => { + await page.goto(`${BASE_URL}/login`); + await expect(page).toHaveTitle(/AirDrama|Airflow/i); + }); + + test('generation page loads after login', async ({ page }) => { + await login(page); + // Should see the input bar + await expect(page.locator('text=人物素材库')).toBeVisible({ timeout: 10000 }); + }); + + test('asset library modal opens', async ({ page }) => { + await login(page); + // Click the asset library button + await page.click('text=人物素材库'); + await page.waitForTimeout(1000); + // Should see the modal + await expect(page.locator('text=上传新角色').first()).toBeVisible({ timeout: 5000 }); + }); + + test('create group flow — name only', async ({ page }) => { + await login(page); + await page.click('text=人物素材库'); + await page.waitForTimeout(1000); + + // Click create + await page.click('text=上传新角色'); + await page.waitForTimeout(500); + + // Should see name input and "创建角色" button + await expect(page.locator('text=角色名称')).toBeVisible(); + await expect(page.locator('text=创建角色')).toBeVisible(); + + // Should NOT see file upload area + await expect(page.locator('text=创建后可在详情页上传图片、视频、音频素材')).toBeVisible(); + }); + + test('asset detail page shows three sections', async ({ page }) => { + await login(page); + await page.click('text=人物素材库'); + await page.waitForTimeout(2000); + + // Click on first group with assets (skip the empty test-e2e group) + const groupNames = page.locator('[class*="cardInfo"]'); + if (await groupNames.count() > 1) { + await groupNames.nth(1).click(); + await page.waitForTimeout(1000); + + // Should see three sections + await expect(page.locator('text=肖像(图片)')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('text=视频').first()).toBeVisible(); + await expect(page.locator('text=音频').first()).toBeVisible(); + + // Should see warning text + await expect(page.locator('text=宽高 300~6000 像素').first()).toBeVisible(); + await expect(page.locator('text=时长 2~15 秒').first()).toBeVisible(); + } + }); + + test('@ mention popup shows individual assets', async ({ page }) => { + await login(page); + + // Type @ in the prompt input + const editor = page.locator('[contenteditable="true"]'); + await editor.click(); + await editor.type('@'); + await page.waitForTimeout(500); + + // If there are references, popup may show "可能@的内容" + // Type a search query + await editor.type('苏'); + await page.waitForTimeout(1000); + + // Check if popup appears with asset results + const popup = page.locator('text=人物素材库匹配'); + if (await popup.isVisible()) { + // Should show individual asset names, not group names + // Should show type badges (图片/视频/音频) + const typeBadges = page.locator('text=图片, text=视频, text=音频'); + // At least one badge should be visible + } + }); + + test('toast component has glass-card style', async ({ page }) => { + await login(page); + + // Trigger a toast by uploading an invalid file format + // Check toast styling includes backdrop-filter + const toastEl = page.locator('[class*="toast"]'); + // Toast may not be visible immediately, this is a structural check + }); + + test('scroll to bottom button appears', async ({ page }) => { + await login(page); + await page.waitForTimeout(2000); + + // If there are enough tasks, scroll up + const contentArea = page.locator('[class*="contentArea"]'); + if (await contentArea.isVisible()) { + await contentArea.evaluate((el) => el.scrollTop = 0); + await page.waitForTimeout(500); + + // Check if "回到底部" button appears + const scrollBtn = page.locator('text=回到底部'); + // May or may not appear depending on content height + } + }); + + test('assets page shows correct order (newest first)', async ({ page }) => { + await login(page); + await page.goto(`${BASE_URL}/assets`); + await page.waitForTimeout(2000); + + // First date group should be "今天" or most recent date + const dateLabels = page.locator('h3'); + if (await dateLabels.count() > 0) { + const firstLabel = await dateLabels.first().textContent(); + // Should be "今天" or a recent date, not an old date + expect(firstLabel).toBeTruthy(); + } + }); + + test('assets page has load more button', async ({ page }) => { + await login(page); + await page.goto(`${BASE_URL}/assets`); + await page.waitForTimeout(2000); + + // If there are more than 20 videos, load more should appear + const loadMore = page.locator('text=加载更多'); + // Just check it doesn't crash + }); +});