This commit is contained in:
parent
6353d2ec4f
commit
ba33c35dd8
12
web/playwright-test.config.ts
Normal file
12
web/playwright-test.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
411
web/test/e2e/v018-test.spec.ts
Normal file
411
web/test/e2e/v018-test.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user