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