Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

This commit is contained in:
zyc 2026-04-04 20:16:15 +08:00
commit 127ed9659d
2 changed files with 423 additions and 0 deletions

View File

@ -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',
},
});

View File

@ -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
});
});