diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..81cae93 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + timeout: 30000, + retries: 0, + use: { + baseURL: 'http://localhost:5173', + headless: true, + launchOptions: { + executablePath: process.env.CHROME_PATH || '/opt/google/chrome/chrome', + }, + }, +}) diff --git a/web/tests/e2e-flow.spec.ts b/web/tests/e2e-flow.spec.ts new file mode 100644 index 0000000..e44b1ff --- /dev/null +++ b/web/tests/e2e-flow.spec.ts @@ -0,0 +1,374 @@ +import { test, expect, type Page, type Request } from '@playwright/test' + +const BASE_URL = 'http://localhost:5173' +const API_URL = 'http://localhost:8080' +const TS = Date.now() + +test.describe.configure({ mode: 'serial' }) + +let uploadedFileId: number +let uploadedFileName: string +let subFolderId: number +let subFolderName: string + +test.describe('完整端到端流程', () => { + + test('1. 创建根级文件夹', async ({ page }) => { + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + + await page.locator('button', { hasText: '新建文件夹' }).click() + await expect(page.locator('.el-dialog__header', { hasText: '新建文件夹' })).toBeVisible() + + subFolderName = `e2e-folder-${TS}` + await page.locator('.el-dialog input').fill(subFolderName) + await page.locator('.el-dialog button', { hasText: '创建' }).click() + + await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 }) + + await expect(page.locator('text=' + subFolderName)).toBeVisible({ timeout: 3000 }) + + const folderRow = page.locator('span', { hasText: new RegExp(subFolderName) }).first() + await expect(folderRow).toBeVisible() + }) + + test('2. 进入文件夹并上传文件', async ({ page }) => { + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + + const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first() + await folderLink.click() + await page.waitForTimeout(500) + + await expect(page.locator('.el-breadcrumb__item').last()).toContainText(`e2e-folder-${TS}`) + + const fileInput = page.locator('input[type="file"]') + await fileInput.setInputFiles({ + name: 'e2e-test-file.txt', + mimeType: 'text/plain', + buffer: Buffer.from(`Hello E2E Test ${TS}`), + }) + + await page.waitForResponse( + resp => resp.url().includes('/api/v1/files/uploads') && resp.status() === 201, + { timeout: 10000 } + ) + + await page.waitForResponse( + resp => resp.url().includes('/complete') && [200, 201].includes(resp.status()), + { timeout: 30000 } + ).catch(() => {}) + + await page.waitForTimeout(1000) + + const table = page.locator('.el-table') + await expect(table).toBeVisible() + + const rows = table.locator('.el-table__body-wrapper .el-table__row') + await expect(rows, 'file should appear in table after upload').toHaveCount(1, { timeout: 10000 }) + await expect(rows.first().locator('td').first()).toContainText('e2e-test-file.txt') + }) + + test('3. 验证文件出现在表格中', async ({ page }) => { + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + + const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first() + await folderLink.click() + await page.waitForTimeout(1000) + + const table = page.locator('.el-table') + await expect(table).toBeVisible() + + const rows = table.locator('.el-table__body-wrapper .el-table__row') + await expect(rows).toHaveCount(1, { timeout: 5000 }) + + const row = rows.first() + await expect(row.locator('td').first()).toContainText('e2e-test-file.txt') + + const sizeCell = row.locator('td').nth(1) + await expect(sizeCell).toContainText(/B|KB|MB/) + + await expect(row.locator('td').nth(2)).toContainText(/text\/plain|application\/octet-stream/) + }) + + test('4. 获取上传文件 ID(通过 API)', async ({ request }) => { + const listResp = await request.get(`${API_URL}/api/v1/files?folder_id=2`) + const body = await listResp.json() + + if (body.data?.files?.length > 0) { + uploadedFileId = body.data.files[0].id + uploadedFileName = body.data.files[0].name + } else { + const allResp = await request.get(`${API_URL}/api/v1/files?search=e2e-test-file`) + const allBody = await allResp.json() + expect(allBody.data.files.length).toBeGreaterThanOrEqual(1) + uploadedFileId = allBody.data.files[0].id + uploadedFileName = allBody.data.files[0].name + } + + expect(uploadedFileId).toBeDefined() + expect(uploadedFileId).toBeGreaterThan(0) + }) + + test('5. 下载文件', async ({ page, context }) => { + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + + const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first() + await folderLink.click() + await page.waitForTimeout(1000) + + const downloadPromise = page.waitForEvent('download', { timeout: 10000 }).catch(() => null) + + await page.locator('button', { hasText: '下载' }).first().click() + + const download = await downloadPromise + if (download) { + const downloadName = download.suggestedFilename() + expect(downloadName).toBeTruthy() + } + }) + + test('6. 搜索文件', async ({ page }) => { + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + + const searchInput = page.locator('input[placeholder="搜索文件"]') + await searchInput.fill('e2e-test-file') + + const [searchResp] = await Promise.all([ + page.waitForResponse( + resp => resp.url().includes('/api/v1/files') && resp.url().includes('search='), + { timeout: 5000 } + ), + searchInput.press('Enter'), + ]) + + expect(searchResp.status()).toBe(200) + const body = await searchResp.json() + expect(body.data.files.length).toBeGreaterThanOrEqual(1) + expect(body.data.files[0].name).toContain('e2e-test-file') + + await expect(page.locator('.el-table__body-wrapper .el-table__row')).toHaveCount( + body.data.files.length, + { timeout: 3000 } + ) + + await searchInput.fill('') + await searchInput.press('Enter') + await page.waitForTimeout(500) + }) + + test('7. 面包屑导航 - 返回根目录', async ({ page }) => { + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + + const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first() + await folderLink.click() + await page.waitForTimeout(1000) + + await expect(page.locator('.el-breadcrumb__item').last()).toContainText(`e2e-folder-${TS}`) + + await page.locator('.el-breadcrumb__item').first().click() + await page.waitForTimeout(1000) + + await expect(page.locator('.el-breadcrumb__item')).toHaveCount(1) + await expect(page.locator('.el-breadcrumb__item').first()).toContainText('全部文件') + + const rootFolders = page.locator('text=e2e-folder-') + await expect(rootFolders.first()).toBeVisible({ timeout: 3000 }) + }) + + test('8. 删除文件', async ({ page }) => { + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + + const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first() + await folderLink.click() + await page.waitForTimeout(1000) + + const rowCountBefore = await page.locator('.el-table__body-wrapper .el-table__row').count() + + page.on('dialog', async dialog => { + expect(dialog.type()).toBe('confirm') + await dialog.accept() + }) + + await page.locator('button', { hasText: '删除' }).first().click() + + await page.waitForTimeout(500) + + await page.locator('.el-message-box').locator('button', { hasText: '确定' }).click().catch(() => { }) + + await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 }) + + await page.waitForTimeout(1000) + + const rowCountAfter = await page.locator('.el-table__body-wrapper .el-table__row').count() + expect(rowCountAfter).toBe(rowCountBefore - 1) + }) + + test('9. 再上传一个文件(用于 FilePicker 测试)', async ({ request, page }) => { + const sha256 = 'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e' + const fileContent = 'Hello World' + + const initResp = await request.post(`${API_URL}/api/v1/files/uploads`, { + data: { + file_name: 'picker-test-file.txt', + file_size: fileContent.length, + sha256, + chunk_size: 16777216, + }, + }) + expect([200, 201]).toContain(initResp.status()) + const initData = await initResp.json() + + if (initResp.status() === 201 && initData.data?.total_chunks) { + const sessionId = initData.data.id + const chunkResp = await request.put(`${API_URL}/api/v1/files/uploads/${sessionId}/chunks/0`, { + multipart: { + chunk: { + name: 'chunk', + mimeType: 'text/plain', + buffer: Buffer.from(fileContent), + }, + }, + }) + expect(chunkResp.status()).toBe(200) + + const completeResp = await request.post(`${API_URL}/api/v1/files/uploads/${sessionId}/complete`) + expect([200, 201]).toContain(completeResp.status()) + const completeData = await completeResp.json() + uploadedFileId = completeData.data.id + uploadedFileName = completeData.data.name + } else { + uploadedFileId = initData.data.id + uploadedFileName = initData.data.name + } + + expect(uploadedFileId).toBeDefined() + expect(uploadedFileId).toBeGreaterThan(0) + }) + + test('10. FilePicker 弹窗 - 选择文件', async ({ page }) => { + await page.goto(`${BASE_URL}/#/tasks/create`) + await page.waitForLoadState('networkidle') + await page.waitForResponse( + resp => resp.url().includes('/api/v1/applications'), + { timeout: 5000 } + ).catch(() => null) + + await page.locator('button', { hasText: '选择文件' }).click() + + await expect(page.locator('.el-dialog__header', { hasText: '选择文件' })).toBeVisible({ timeout: 3000 }) + + await expect(page.locator('.el-dialog .el-breadcrumb')).toBeVisible() + await expect(page.locator('.el-dialog .el-breadcrumb__item').first()).toContainText('全部文件') + + await page.waitForTimeout(500) + const checkboxes = page.locator('.el-dialog .el-checkbox') + const checkboxCount = await checkboxes.count() + + if (checkboxCount > 0) { + await checkboxes.first().click() + + await expect(page.locator('.el-dialog button', { hasText: /确认/ })).toBeVisible() + await page.locator('.el-dialog button', { hasText: /确认/ }).click() + + await page.waitForTimeout(500) + const tags = page.locator('.el-tag') + await expect(tags.first()).toBeVisible({ timeout: 3000 }) + + const firstTag = tags.first() + await expect(firstTag.locator('.el-tag__content')).toContainText(/\.txt/) + + const removeIcon = firstTag.locator('.el-tag__close') + await removeIcon.click() + await expect(page.locator('.el-tag')).toHaveCount(0, { timeout: 2000 }) + } else { + await page.locator('.el-dialog button', { hasText: '取消' }).click() + } + }) + + test('11. FilePicker 多选测试', async ({ request, page }) => { + const files: { id: number; name: string }[] = [] + for (let i = 0; i < 3; i++) { + const content = `multi-file-${i}-${TS}` + const sha256 = await computeSHA256(content) + const initResp = await request.post(`${API_URL}/api/v1/files/uploads`, { + data: { file_name: content + '.txt', file_size: content.length, sha256, chunk_size: 16777216 }, + }) + const initData = await initResp.json() + + if (initResp.status() === 201 && initData.data?.total_chunks) { + const sid = initData.data.id + await request.put(`${API_URL}/api/v1/files/uploads/${sid}/chunks/0`, { + multipart: { chunk: { name: 'chunk', mimeType: 'text/plain', buffer: Buffer.from(content) } }, + }) + const cResp = await request.post(`${API_URL}/api/v1/files/uploads/${sid}/complete`) + const cData = await cResp.json() + files.push({ id: cData.data.id, name: cData.data.name }) + } else { + files.push({ id: initData.data.id, name: initData.data.name }) + } + } + + await page.goto(`${BASE_URL}/#/tasks/create`) + await page.waitForLoadState('networkidle') + await page.waitForResponse(r => r.url().includes('/api/v1/applications'), { timeout: 5000 }).catch(() => null) + + await page.locator('button', { hasText: '选择文件' }).click() + await expect(page.locator('.el-dialog__header', { hasText: '选择文件' })).toBeVisible({ timeout: 3000 }) + await page.waitForTimeout(500) + + const checkboxes = page.locator('.el-dialog .el-checkbox') + const count = await checkboxes.count() + const toSelect = Math.min(count, 3) + + for (let i = 0; i < toSelect; i++) { + await checkboxes.nth(i).click() + } + + await page.locator('.el-dialog button', { hasText: /确认/ }).click() + await page.waitForTimeout(500) + + if (toSelect > 0) { + const tags = page.locator('.el-tag') + await expect(tags).toHaveCount(toSelect, { timeout: 3000 }) + } + }) + + test('12. 删除文件夹', async ({ page }) => { + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + + const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first() + const folderRow = folderLink.locator('xpath=ancestor::div[contains(@style, "cursor: pointer")]') + + const deleteBtn = folderRow.locator('button', { hasText: '删除' }).first() + if (await deleteBtn.isVisible().catch(() => false)) { + await deleteBtn.click() + + await page.waitForTimeout(500) + const confirmBtn = page.locator('.el-message-box').locator('button', { hasText: '确定' }) + if (await confirmBtn.isVisible().catch(() => false)) { + await confirmBtn.click() + await page.waitForTimeout(2000) + + const successMsg = page.locator('.el-message--success') + if (await successMsg.isVisible().catch(() => false)) { + } else { + const errorMsg = page.locator('.el-message--error') + if (await errorMsg.isVisible().catch(() => false)) { + } + } + } + } + }) +}) + +async function computeSHA256(content: string): Promise { + const crypto = await import('crypto') + return crypto.createHash('sha256').update(content).digest('hex') +} diff --git a/web/tests/file-manager.spec.ts b/web/tests/file-manager.spec.ts new file mode 100644 index 0000000..ea8feb3 --- /dev/null +++ b/web/tests/file-manager.spec.ts @@ -0,0 +1,254 @@ +import { test, expect } from '@playwright/test' + +const BASE_URL = 'http://localhost:5173' +const API_URL = 'http://localhost:8080' + +test.describe('文件管理模块', () => { + + test.beforeEach(async ({ page }) => { + // Navigate to file manager page + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + }) + + test('侧边栏有文件管理菜单项', async ({ page }) => { + // Check sidebar menu item exists + const fileMenuItem = page.locator('.el-menu-item').filter({ hasText: '文件管理' }) + await expect(fileMenuItem).toBeVisible() + + // Verify FolderOpened icon is present + const icon = fileMenuItem.locator('.el-icon') + await expect(icon).toBeVisible() + }) + + test('文件管理页面基本结构', async ({ page }) => { + // Page title + await expect(page.locator('h2', { hasText: '文件管理' })).toBeVisible() + + // Breadcrumb navigation + await expect(page.locator('.el-breadcrumb')).toBeVisible() + await expect(page.locator('.el-breadcrumb__item').first()).toContainText('全部文件') + + // Upload button + await expect(page.locator('button', { hasText: '上传文件' })).toBeVisible() + + // Create folder button + await expect(page.locator('button', { hasText: '新建文件夹' })).toBeVisible() + + // Search input + await expect(page.locator('input[placeholder="搜索文件"]')).toBeVisible() + + // File table (may be empty) + await expect(page.locator('.el-table')).toBeVisible() + }) + + test('API 连接正常 - 空文件列表', async ({ page }) => { + // Table exists (even if empty - API already responded during navigation) + const table = page.locator('.el-table') + await expect(table).toBeVisible() + }) + + test('创建文件夹', async ({ page }) => { + // Click create folder button + await page.locator('button', { hasText: '新建文件夹' }).click() + + // Dialog should appear + await expect(page.locator('.el-dialog__header', { hasText: '新建文件夹' })).toBeVisible() + + // Type folder name + const dialog = page.locator('.el-dialog') + await dialog.locator('input').fill('test-folder-' + Date.now()) + + // Click create button + await dialog.locator('button', { hasText: '创建' }).click() + + // Wait for success message + await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 }) + + // Folder should appear in the list + await page.waitForTimeout(500) + }) + + test('搜索框交互', async ({ page }) => { + const searchInput = page.locator('input[placeholder="搜索文件"]') + await expect(searchInput).toBeVisible() + + // Type search query + await searchInput.fill('test') + + // Press enter to trigger search + await searchInput.press('Enter') + + // Should make API call with search parameter + const response = await page.waitForResponse( + resp => resp.url().includes('/api/v1/files') && resp.url().includes('search=test'), + { timeout: 5000 } + ).catch(() => null) + + // Response might be null if server already responded, that's ok + // The important thing is the search input still has the value + await expect(searchInput).toHaveValue('test') + + // Clear search + await searchInput.fill('') + await searchInput.press('Enter') + }) + + test('面包屑导航 - 初始状态', async ({ page }) => { + // Initial breadcrumb should show "全部文件" + const breadcrumbs = page.locator('.el-breadcrumb__item') + await expect(breadcrumbs.first()).toContainText('全部文件') + }) +}) + +test.describe('任务提交 - 文件选择器集成', () => { + + test('提交任务页面有文件选择功能', async ({ page }) => { + await page.goto(`${BASE_URL}/#/tasks/create`) + await page.waitForLoadState('networkidle') + + // Wait for applications to load + await page.waitForResponse( + resp => resp.url().includes('/api/v1/applications'), + { timeout: 5000 } + ).catch(() => null) + + // Check "关联文件" form item exists + await expect(page.locator('.el-form-item', { hasText: '关联文件' })).toBeVisible() + + // Check "选择文件" button exists + await expect(page.locator('button', { hasText: '选择文件' })).toBeVisible() + }) + + test('点击选择文件打开 FilePicker 弹窗', async ({ page }) => { + await page.goto(`${BASE_URL}/#/tasks/create`) + await page.waitForLoadState('networkidle') + + // Wait for applications to load + await page.waitForResponse( + resp => resp.url().includes('/api/v1/applications'), + { timeout: 5000 } + ).catch(() => null) + + // Click "选择文件" button + await page.locator('button', { hasText: '选择文件' }).click() + + // FilePicker dialog should appear + await expect(page.locator('.el-dialog__header', { hasText: '选择文件' })).toBeVisible({ timeout: 3000 }) + + // Should have breadcrumb navigation + await expect(page.locator('.el-dialog .el-breadcrumb')).toBeVisible() + + // Should have confirm and cancel buttons + await expect(page.locator('.el-dialog button', { hasText: '取消' })).toBeVisible() + await expect(page.locator('.el-dialog button', { hasText: '确认' })).toBeVisible() + + // Close dialog + await page.locator('.el-dialog button', { hasText: '取消' }).click() + }) +}) + +test.describe('文件上传流程', () => { + + test('上传按钮触发文件选择', async ({ page }) => { + await page.goto(`${BASE_URL}/#/files`) + await page.waitForLoadState('networkidle') + + // Upload button should be visible and enabled + const uploadBtn = page.locator('button', { hasText: '上传文件' }) + await expect(uploadBtn).toBeVisible() + await expect(uploadBtn).toBeEnabled() + + // Click should trigger file input + const fileInput = page.locator('input[type="file"]') + await expect(fileInput).toBeAttached() + }) +}) + +test.describe('API 端点验证', () => { + + test('GET /api/v1/files 返回正确格式', async ({ request }) => { + const resp = await request.get(`${API_URL}/api/v1/files`) + expect(resp.status()).toBe(200) + const body = await resp.json() + expect(body.success).toBe(true) + expect(body.data).toBeDefined() + expect(body.data.files).toBeInstanceOf(Array) + expect(typeof body.data.total).toBe('number') + }) + + test('GET /api/v1/files/folders 返回正确格式', async ({ request }) => { + const resp = await request.get(`${API_URL}/api/v1/files/folders`) + expect(resp.status()).toBe(200) + const body = await resp.json() + expect(body.success).toBe(true) + expect(body.data).toBeInstanceOf(Array) + }) + + test('POST /api/v1/files/folders 创建文件夹', async ({ request }) => { + const folderName = `test-folder-${Date.now()}` + const resp = await request.post(`${API_URL}/api/v1/files/folders`, { + data: { name: folderName } + }) + expect([200, 201]).toContain(resp.status()) + const body = await resp.json() + expect(body.success).toBe(true) + expect(body.data.name).toBe(folderName) + expect(body.data.id).toBeDefined() + expect(typeof body.data.file_count).toBe('number') + expect(typeof body.data.subfolder_count).toBe('number') + }) + + test('POST /api/v1/files/uploads/init - 秒传测试', async ({ request }) => { + // Use a known SHA256 to test instant upload behavior + const resp = await request.post(`${API_URL}/api/v1/files/uploads`, { + data: { + file_name: 'test.txt', + file_size: 5, + sha256: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + chunk_size: 16777216, + } + }) + // Will be either 200 (new upload session) or some error + // Just verify the endpoint is reachable + expect([200, 201, 400, 500]).toContain(resp.status()) + }) + + test('创建子文件夹并在列表中看到', async ({ request }) => { + // Create parent folder + const parentResp = await request.post(`${API_URL}/api/v1/files/folders`, { + data: { name: `parent-${Date.now()}` } + }) + const parent = await parentResp.json() + const parentId = parent.data.id + + // Create child folder + const childResp = await request.post(`${API_URL}/api/v1/files/folders`, { + data: { name: `child-${Date.now()}`, parent_id: parentId } + }) + expect([200, 201]).toContain(childResp.status()) + const child = await childResp.json() + expect(child.data.parent_id).toBe(parentId) + + // List folders under parent + const listResp = await request.get(`${API_URL}/api/v1/files/folders?parent_id=${parentId}`) + expect(listResp.status()).toBe(200) + const list = await listResp.json() + expect(list.data.length).toBeGreaterThanOrEqual(1) + }) + + test('删除空文件夹成功', async ({ request }) => { + // Create a folder + const createResp = await request.post(`${API_URL}/api/v1/files/folders`, { + data: { name: `delete-me-${Date.now()}` } + }) + const folder = await createResp.json() + const folderId = folder.data.id + + // Delete it (should succeed - empty folder) + const deleteResp = await request.delete(`${API_URL}/api/v1/files/folders/${folderId}`) + expect(deleteResp.status()).toBe(200) + const deleteBody = await deleteResp.json() + expect(deleteBody.success).toBe(true) + }) +})