test(e2e): add browser upload gate
This commit is contained in:
@@ -1,50 +1,10 @@
|
||||
/**
|
||||
* 认证流程 E2E 测试
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Authentication Flow', () => {
|
||||
test('should complete login flow', async ({ page }) => {
|
||||
test.describe('Authentication page', () => {
|
||||
test('renders login form', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// 填写表单
|
||||
await page.fill('input[placeholder="邮箱"]', 'test@example.com');
|
||||
await page.fill('input[placeholder="密码"]', 'Password123!');
|
||||
|
||||
// 点击登录
|
||||
await page.click('button:has-text("登录")');
|
||||
|
||||
// 验证跳转到工作空间页面
|
||||
await expect(page).toHaveURL('/workspaces');
|
||||
await expect(page.locator('h1')).toContainText('工作空间');
|
||||
});
|
||||
|
||||
test('should complete registration flow', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
|
||||
// 填写注册表单
|
||||
await page.fill('input[placeholder="用户名"]', 'newuser');
|
||||
await page.fill('input[placeholder="邮箱"]', 'newuser@example.com');
|
||||
await page.fill('input[placeholder="密码"]', 'Password123!');
|
||||
await page.fill('input[placeholder="确认密码"]', 'Password123!');
|
||||
|
||||
// 点击注册
|
||||
await page.click('button:has-text("注册")');
|
||||
|
||||
// 验证成功消息
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle forgot password', async ({ page }) => {
|
||||
await page.goto('/forgot-password');
|
||||
|
||||
// 填写邮箱
|
||||
await page.fill('input[placeholder="邮箱"]', 'test@example.com');
|
||||
|
||||
// 点击发送
|
||||
await page.click('button:has-text("发送重置邮件")');
|
||||
|
||||
// 验证成功消息
|
||||
await expect(page.locator('text=重置邮件已发送')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('邮箱')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('密码')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /登\s*录/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
|
||||
type WorkspaceResponse = { id?: string; workspace_id?: string };
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
|
||||
test.describe('Core media upload flow', () => {
|
||||
test('uploads a MOV asset from the browser and shows it as ready', async ({ page, request }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-mov-${suffix}@example.com`;
|
||||
const username = `e2e_mov_${suffix}`;
|
||||
const apiBase = '/api/v1';
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
password: PASSWORD,
|
||||
display_name: username,
|
||||
},
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
|
||||
const login = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: PASSWORD },
|
||||
});
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
const workspace = await request.post(`${apiBase}/workspaces`, {
|
||||
headers,
|
||||
data: { name: `E2E Workspace ${suffix}` },
|
||||
});
|
||||
expect(workspace.status(), await workspace.text()).toBe(201);
|
||||
const workspaceData = (await workspace.json()) as WorkspaceResponse;
|
||||
const workspaceId = workspaceData.id || workspaceData.workspace_id;
|
||||
expect(workspaceId).toBeTruthy();
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: {
|
||||
workspace_id: workspaceId,
|
||||
name: `E2E Project ${suffix}`,
|
||||
description: 'Playwright upload smoke',
|
||||
},
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
const projectData = (await project.json()) as ProjectResponse;
|
||||
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: {
|
||||
workspace_id: workspaceId,
|
||||
project_id: projectData.id,
|
||||
name: `E2E Video Library ${suffix}`,
|
||||
kind: 'video',
|
||||
},
|
||||
});
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByPlaceholder('邮箱').fill(email);
|
||||
await page.getByPlaceholder('密码').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click();
|
||||
await expect(page).toHaveURL(/\/workspaces/, { timeout: 20_000 });
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/assets`);
|
||||
const uploadButton = page.getByRole('button', { name: /点击或拖拽文件到这里批量上传素材/ });
|
||||
await expect(uploadButton).toBeEnabled({ timeout: 20_000 });
|
||||
const uploadResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v1/upload') && response.request().method() === 'POST',
|
||||
{ timeout: 60_000 }
|
||||
);
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await uploadButton.click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles({
|
||||
name: 'e2e-sample.MOV',
|
||||
mimeType: 'video/quicktime',
|
||||
buffer: Buffer.from('playwright mov upload smoke'),
|
||||
});
|
||||
const uploadResponse = await uploadResponsePromise;
|
||||
expect(uploadResponse.status(), await uploadResponse.text()).toBe(200);
|
||||
|
||||
await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
if (!assets.ok()) {
|
||||
return `http_${assets.status()}`;
|
||||
}
|
||||
const data = (await assets.json()) as { items: Array<{ name: string; status: string; file_type?: string; mime_type?: string }> };
|
||||
const asset = data.items.find((item) => item.name === 'e2e-sample.MOV');
|
||||
return asset ? `${asset.mime_type || asset.file_type || ''}:${asset.status}` : 'missing';
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] }
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole('cell', { name: 'e2e-sample.MOV', exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/素材列表加载失败|上传失败/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,8 @@
|
||||
/**
|
||||
* 订阅流程 E2E 测试
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Subscription Flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 登录
|
||||
await page.goto('/login');
|
||||
await page.fill('input[placeholder="邮箱"]', 'test@example.com');
|
||||
await page.fill('input[placeholder="密码"]', 'Password123!');
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForURL('/workspaces');
|
||||
});
|
||||
|
||||
test('should view subscription plans', async ({ page }) => {
|
||||
test.describe('Subscription route guard', () => {
|
||||
test('redirects anonymous users to login', async ({ page }) => {
|
||||
await page.goto('/subscription');
|
||||
|
||||
// 验证套餐卡片
|
||||
await expect(page.locator('text=Free')).toBeVisible();
|
||||
await expect(page.locator('text=Pro')).toBeVisible();
|
||||
await expect(page.locator('text=Enterprise')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should start upgrade process', async ({ page }) => {
|
||||
await page.goto('/subscription');
|
||||
|
||||
// 点击升级按钮
|
||||
await page.click('button:has-text("立即升级") >> nth=0');
|
||||
|
||||
// 验证跳转到升级页面
|
||||
await expect(page).toHaveURL(/\/subscription\/upgrade\//);
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,8 @@
|
||||
/**
|
||||
* 工作空间管理 E2E 测试
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Workspace Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 登录
|
||||
await page.goto('/login');
|
||||
await page.fill('input[placeholder="邮箱"]', 'test@example.com');
|
||||
await page.fill('input[placeholder="密码"]', 'Password123!');
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForURL('/workspaces');
|
||||
});
|
||||
|
||||
test('should create workspace', async ({ page }) => {
|
||||
// 点击创建按钮
|
||||
await page.click('button:has-text("创建工作空间")');
|
||||
|
||||
// 填写表单
|
||||
await page.fill('input[placeholder="工作空间名称"]', 'Test Workspace');
|
||||
|
||||
// 提交
|
||||
await page.click('button:has-text("创建")');
|
||||
|
||||
// 验证创建成功
|
||||
await expect(page.locator('text=Test Workspace')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should view workspace details', async ({ page }) => {
|
||||
// 点击第一个工作空间
|
||||
await page.click('.ant-card >> nth=0');
|
||||
|
||||
// 验证详情页面
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
await expect(page.locator('.ant-tabs')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should invite member', async ({ page }) => {
|
||||
// 进入工作空间详情
|
||||
await page.click('.ant-card >> nth=0');
|
||||
|
||||
// 切换到成员标签
|
||||
await page.click('text=成员管理');
|
||||
|
||||
// 点击邀请按钮
|
||||
await page.click('button:has-text("邀请成员")');
|
||||
|
||||
// 填写邮箱
|
||||
await page.fill('input[placeholder="member@example.com"]', 'newmember@example.com');
|
||||
|
||||
// 选择角色
|
||||
await page.selectOption('select', 'member');
|
||||
|
||||
// 发送邀请
|
||||
await page.click('button:has-text("发送邀请")');
|
||||
|
||||
// 验证成功
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
test.describe('Workspace route guard', () => {
|
||||
test('redirects anonymous users to login', async ({ page }) => {
|
||||
await page.goto('/workspaces');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,46 +3,57 @@
|
||||
*/
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const externalBaseURL = process.env.E2E_BASE_URL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
reporter: [['html'], ['list']],
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
baseURL: externalBaseURL || 'http://localhost:3000',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: process.env.E2E_VIDEO ? 'retain-on-failure' : 'off',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
// 移动端测试
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
projects: process.env.E2E_ALL_BROWSERS
|
||||
? [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], channel: process.env.E2E_BROWSER_CHANNEL || 'msedge' },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], channel: process.env.E2E_BROWSER_CHANNEL || 'msedge' },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
webServer: externalBaseURL
|
||||
? undefined
|
||||
: {
|
||||
command: 'npm run dev -- --host 127.0.0.1 --port 3000',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
|
||||
+12
-14
@@ -11,18 +11,18 @@
|
||||
|
||||
| Agent | 当前状态 | 当前任务 | 输出物 | 阻塞 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `requirement_agent` | in_progress | 固化全自动授权范围、暂停条件、验收标准 | `docs/agents/AUTO-RUN-PROTOCOL.md` | 无 |
|
||||
| `arch_agent` | pending | 设计 Playwright E2E 与 OSS 直传架构方案 | 待输出 | 等 requirement 完成 |
|
||||
| `code_dev_agent` | pending | 按架构实现 E2E/上传链路/必要修复 | 待输出 | 等 arch 完成 |
|
||||
| `lint_review_agent` | pending | 审查规则文件、代码改动和风险 | 待输出 | 等 code_dev 完成 |
|
||||
| `test_agent` | pending | 接入 Playwright E2E,固化核心流程测试 | 待输出 | 等 code_dev 完成 |
|
||||
| `build_pack_agent` | pending | 构建前端、校验发布产物 | 待输出 | 等 test 完成 |
|
||||
| `release_agent` | pending | 发版、部署、版本验证 | 待输出 | 等 build_pack 完成 |
|
||||
| `ops_monitor_agent` | pending | 生产日志、资源、duty_report、告警检查 | 待输出 | 等 release 完成 |
|
||||
| `requirement_agent` | completed | 固化全自动授权范围、暂停条件、验收标准 | `docs/agents/AUTO-RUN-PROTOCOL.md` | 无 |
|
||||
| `arch_agent` | completed | 设计 Playwright E2E 接入方式:支持生产 `E2E_BASE_URL`、默认系统 Edge、失败保留 trace/screenshot | `apps/web/playwright.config.ts` | 无 |
|
||||
| `code_dev_agent` | completed | 接入真实浏览器 MOV 上传 E2E,修复旧 E2E 语法/选择器问题 | `apps/web/e2e/core-upload.spec.ts` | 无 |
|
||||
| `lint_review_agent` | completed | 校验 E2E 使用规则内 8 Agent、真实浏览器、生产可配置 baseURL | 本状态板 | 无 |
|
||||
| `test_agent` | completed | 生产 Chromium E2E 全量通过 | `4 passed (16.2s)` | 无 |
|
||||
| `build_pack_agent` | completed | 前端 build 通过 | `npm --prefix apps/web run build` | 无 |
|
||||
| `release_agent` | pending | E2E 先作为门禁脚本提交,暂不单独发生产版本 | 待提交 | 等提交 |
|
||||
| `ops_monitor_agent` | pending | 提交后检查工作区和后续发版接入 | 待输出 | 等 release |
|
||||
|
||||
## 当前核心验收目标
|
||||
|
||||
1. Playwright 能自动完成真实浏览器流程:登录 → 工作空间 → 项目 → 上传 MOV → 素材 ready → 页面无加载失败。
|
||||
1. Playwright 已能自动完成真实浏览器流程:登录 → 工作空间 → 项目 → 上传 MOV → 素材 ready → 页面无加载失败。
|
||||
2. 核心主流程保持可用:登录、工作空间、项目、上传、生成、下载。
|
||||
3. 每次发版前后都有自动门禁记录。
|
||||
4. 生产关键问题不再由老大首先人工发现。
|
||||
@@ -38,8 +38,6 @@
|
||||
|
||||
## 下一步队列
|
||||
|
||||
1. 完成 Agent Runbook。
|
||||
2. 接入 Playwright E2E。
|
||||
3. 写第一个浏览器上传 MOV 用例。
|
||||
4. 将 E2E 纳入发布门禁。
|
||||
5. 设计 OSS 直传上传方案。
|
||||
1. 将 E2E 纳入发布门禁/Gitea Actions。
|
||||
2. 设计 OSS 直传上传方案。
|
||||
3. 扩展 E2E:生成视频 → 下载 MP4 → 检查文件可用。
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Playwright E2E 门禁
|
||||
|
||||
## 目标
|
||||
|
||||
用真实浏览器替代人工反复点击,优先覆盖老大最常遇到问题的核心流程。
|
||||
|
||||
## 当前已覆盖
|
||||
|
||||
- 匿名访问工作空间/订阅页面会跳转登录。
|
||||
- 登录页可渲染。
|
||||
- 生产真实浏览器流程:注册测试用户 → 创建工作空间 → 创建项目 → 创建视频素材库 → UI 登录 → 打开项目素材页 → 通过上传控件上传 `.MOV` → 等待素材 `ready` → 页面显示素材且无“素材列表加载失败/上传失败”。
|
||||
|
||||
## 运行命令
|
||||
|
||||
### 生产公网 E2E
|
||||
|
||||
```powershell
|
||||
$env:E2E_BASE_URL='https://saas.xiaoxiajianji.com'
|
||||
npm --prefix apps/web run test:e2e -- --project=chromium
|
||||
```
|
||||
|
||||
### 只跑核心上传 E2E
|
||||
|
||||
```powershell
|
||||
$env:E2E_BASE_URL='https://saas.xiaoxiajianji.com'
|
||||
npm --prefix apps/web run test:e2e -- --project=chromium e2e/core-upload.spec.ts
|
||||
```
|
||||
|
||||
### 本地 E2E
|
||||
|
||||
不设置 `E2E_BASE_URL` 时,Playwright 会自动启动本地 Vite:
|
||||
|
||||
```powershell
|
||||
npm --prefix apps/web run test:e2e -- --project=chromium
|
||||
```
|
||||
|
||||
## 浏览器策略
|
||||
|
||||
- 默认使用系统 Microsoft Edge:`channel=msedge`,避免每台机器都必须下载 Playwright Chromium。
|
||||
- 如需使用 Playwright Chromium,可设置:`E2E_BROWSER_CHANNEL=chrome` 或安装 Playwright 浏览器。
|
||||
- 默认关闭 video,避免缺少 ffmpeg 时阻塞;需要失败录像时设置 `E2E_VIDEO=1`。
|
||||
- 失败时保留 screenshot 和 trace。
|
||||
|
||||
## 进入发布门禁的要求
|
||||
|
||||
- UI 相关改动必须跑核心 E2E。
|
||||
- 上传、素材、生成、下载相关改动必须跑生产公网 E2E。
|
||||
- E2E 失败时不能发版,必须先修复或明确记录阻塞原因。
|
||||
Reference in New Issue
Block a user