import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { expect, test, type APIRequestContext } from '@playwright/test' const venvProject = fileURLToPath( new URL('../../.e2e-data/venv-project', import.meta.url), ) function createVenvFixture(): string { const scripts = join(venvProject, '.venv', 'Scripts') mkdirSync(scripts, { recursive: true }) writeFileSync(join(venvProject, '.venv', 'pyvenv.cfg'), 'home = C:\\Python\n') writeFileSync(join(scripts, 'python.exe'), '') return venvProject } const taskPayload = { id: 'e2e_echo', name: 'E2E echo', cron: '0 0 2 * * *', timezone: 'Asia/Shanghai', enabled: true, timeout_secs: 10, action: { type: 'script', working_dir: '.', interpreter: 'cmd.exe', script: '/C', args: ['echo', 'e2e-output'], env: {}, }, } async function authHeaders(request: APIRequestContext) { const response = await request.get('/api/v1/session') expect(response.ok()).toBeTruthy() const session = (await response.json()) as { token: string } return { 'X-TaskPilot-Token': session.token } } test('manages a task and reads a real run log', async ({ page, request }) => { const headers = await authHeaders(request) const existing = await request.get('/api/v1/tasks?page=1&page_size=100') expect(existing.ok()).toBeTruthy() const existingBody = (await existing.json()) as { items: Array<{ id: string }> } for (const task of existingBody.items) { await request.delete(`/api/v1/tasks/${task.id}`, { headers }) } const createResponse = await request.post('/api/v1/tasks', { data: taskPayload, headers, }) expect(createResponse.status()).toBe(201) await page.goto('/') await expect(page.getByText('E2E echo')).toBeVisible() await page.screenshot({ path: 'test-results/dashboard-desktop.png', fullPage: true, }) await page.getByRole('button', { name: '+ 新建任务' }).click() const newTaskDialog = page.getByRole('dialog', { name: '新建定时任务' }) await expect(newTaskDialog).toBeVisible() await expect(newTaskDialog.getByLabel('任务标识')).toBeFocused() await page.screenshot({ path: 'test-results/dashboard-task-editor.png', fullPage: true, }) const workingDir = createVenvFixture() const workingDirInput = newTaskDialog.getByLabel(/项目工作目录/) // Cron 预览是异步渲染的,等它稳定后再点击,避免点击坐标在弹窗重排中失效。 await expect(newTaskDialog.locator('.cron-run-item').first()).toBeVisible() await newTaskDialog.getByRole('button', { name: '选择文件夹…' }).click() const directoryPicker = page.getByRole('dialog', { name: '选择工作目录' }) await expect(directoryPicker).toBeVisible() const directoryPathInput = directoryPicker.getByLabel('目录路径') await directoryPathInput.fill(workingDir) await directoryPathInput.press('Enter') await expect( directoryPicker.getByRole('button', { name: '使用此目录' }), ).toBeEnabled() await page.screenshot({ path: 'test-results/dashboard-directory-picker.png', fullPage: true, }) await directoryPicker.getByRole('button', { name: '使用此目录' }).click() await expect(directoryPicker).toBeHidden() await expect(workingDirInput).toHaveValue(workingDir) await expect( newTaskDialog.locator('.env-status-badge.ok code'), ).toHaveText('.venv\\Scripts\\python.exe') await expect(newTaskDialog.getByText(/已关联虚拟环境/)).toBeVisible() await page.screenshot({ path: 'test-results/dashboard-venv-detected.png', fullPage: true, }) const addArgumentButton = newTaskDialog.getByRole('button', { name: '+ 添加参数', }) await addArgumentButton.focus() await page.keyboard.press('Enter') await newTaskDialog .getByPlaceholder('参数名 (如 --mode 或 -v)') .fill('--mode') await newTaskDialog .getByPlaceholder('参数值 (可选,无值可留空)') .fill('full') await newTaskDialog .getByLabel(/虚拟环境目录/) .fill('.venv') await newTaskDialog .getByText('调用预览 (Runtime Preview)') .scrollIntoViewIfNeeded() await expect( newTaskDialog.getByText( '.venv\\Scripts\\python.exe main.py --mode full', { exact: false }, ), ).toBeVisible() await page.screenshot({ path: 'test-results/dashboard-task-editor-runtime.png', fullPage: true, }) await page.keyboard.press('Escape') await expect(newTaskDialog).toBeHidden() await page.getByRole('button', { name: '运行' }).click() const drawer = page.getByRole('dialog', { name: 'E2E echo' }) await expect(drawer).toBeVisible() await expect(drawer.getByText('e2e-output')).toBeVisible() await expect(drawer.locator('.status-succeeded').first()).toBeVisible() await page.screenshot({ path: 'test-results/dashboard-log-drawer.png', fullPage: true, }) await drawer.getByRole('button', { name: '关闭' }).click() // 运行已结束时再打开日志,也要能回放(曾因 rAF 句柄未归零停在空态)。 await page.getByRole('button', { name: '日志' }).click() const replayDrawer = page.getByRole('dialog', { name: 'E2E echo' }) await expect(replayDrawer).toBeVisible() await expect(replayDrawer.getByText('e2e-output')).toBeVisible() await replayDrawer.getByRole('button', { name: '关闭' }).click() await page.getByRole('button', { name: '编辑' }).click() const editor = page.getByRole('dialog', { name: '编辑任务 [e2e_echo]', }) await expect(editor).toBeVisible() const nameInput = editor.getByLabel('名称') await nameInput.fill('E2E updated') await editor.getByRole('button', { name: '保存修改' }).click() await expect(page.getByText('E2E updated')).toBeVisible() await page.getByRole('button', { name: '停用' }).click() await expect( page .getByRole('row', { name: /E2E updated/ }) .locator('.status-disabled'), ).toBeVisible() await page.getByRole('button', { name: '删除' }).click() await page .getByRole('dialog', { name: /删除任务/ }) .getByRole('button', { name: '确认删除' }) .click() await expect(page.getByText('还没有任务')).toBeVisible() }) test('switches script language and adapts the form fields', async ({ page }) => { await page.goto('/') await page.getByRole('button', { name: '+ 新建任务' }).click() const dialog = page.getByRole('dialog', { name: '新建定时任务' }) await expect(dialog).toBeVisible() await dialog.getByRole('button', { name: 'Python', exact: true }).click() await page.getByRole('option', { name: 'Shell (cmd)' }).click() await expect(dialog.getByText(/虚拟环境目录/)).toHaveCount(0) await expect(dialog.getByLabel('批处理文件或相对路径')).toHaveValue('run.bat') await expect(dialog.locator('.cli-line-cmd')).toContainText( 'cmd.exe /C run.bat', ) await dialog.getByRole('button', { name: 'Shell (cmd)' }).click() await page.getByRole('option', { name: 'Node.js' }).click() await expect(dialog.getByLabel('JavaScript 文件或相对路径')).toHaveValue( 'index.js', ) await expect(dialog.locator('.cli-line-cmd')).toContainText( 'node.exe index.js', ) await page.screenshot({ path: 'test-results/dashboard-script-language.png', fullPage: true, }) await dialog.getByRole('button', { name: '取消' }).click() }) test('avoids a transient invalid venv when switching directories', async ({ page, }) => { const venvDir = createVenvFixture() const emptyProject = join(venvDir, '..', 'empty-project') mkdirSync(emptyProject, { recursive: true }) await page.goto('/') await page.getByRole('button', { name: '+ 新建任务' }).click() const dialog = page.getByRole('dialog', { name: '新建定时任务' }) const workingDir = dialog.getByLabel(/项目工作目录/) const bar = dialog.locator('.env-detector-bar') await workingDir.fill(venvDir) await expect(bar).toContainText('已关联虚拟环境') await workingDir.fill(emptyProject) const states = new Set() for (let index = 0; index < 30; index += 1) { states.add(await bar.innerText()) await page.waitForTimeout(80) } expect([...states].some((text) => text.includes('缺少'))).toBe(false) await expect(bar).toContainText('未检测到虚拟环境') }) test('keeps the dashboard usable on a narrow viewport', async ({ page, request, }) => { const headers = await authHeaders(request) await request.post('/api/v1/tasks', { headers, data: { ...taskPayload, id: 'e2e_mobile', name: 'Mobile task', }, }) await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/') await expect(page.getByText('Mobile task')).toBeVisible() await expect(page.getByRole('button', { name: '+ 新建任务' })).toBeVisible() await page.screenshot({ path: 'test-results/dashboard-mobile.png', fullPage: true, }) }) test('cancels a running task from the log drawer', async ({ page, request, }) => { const headers = await authHeaders(request) const response = await request.post('/api/v1/tasks', { headers, data: { ...taskPayload, id: 'e2e_cancel', name: 'Cancel task', timeout_secs: 30, action: { ...taskPayload.action, args: ['ping', '-t', '127.0.0.1'], }, }, }) expect(response.status()).toBe(201) await page.goto('/') await page .getByRole('row', { name: /Cancel task/ }) .getByRole('button', { name: '运行' }) .click() const drawer = page.getByRole('dialog', { name: 'Cancel task' }) await expect(drawer).toBeVisible() await expect(drawer.getByText('SSE 实时连接')).toBeVisible() await drawer.getByRole('button', { name: '取消运行' }).click() await expect(drawer.locator('.status-cancelled').first()).toBeVisible() }) test('renders without external network access', async ({ page }) => { const blocked: string[] = [] await page.route('**/*', (route) => { const url = route.request().url() const isLocal = url.startsWith('http://127.0.0.1:5173') || url.startsWith('http://localhost:5173') || url.startsWith('data:') || url.startsWith('blob:') if (isLocal) { return route.continue() } blocked.push(url) return route.abort() }) await page.goto('/') await expect( page.getByRole('heading', { name: '任务调度' }), ).toBeVisible() const state = await page.evaluate(async () => { // 字体二进制不入库;未生成时应回退系统字体,而不是报错。 const probe = await fetch('/fonts/LXGWWenKai-Regular.woff2', { method: 'HEAD', }) await document.fonts.ready return { wenkaiAvailable: probe.ok, wenkaiLoaded: document.fonts.check('14px "LXGW WenKai"'), displayLoaded: document.fonts.check('500 26px "Outfit-Medium"'), } }) expect(blocked).toEqual([]) expect(state.displayLoaded).toBe(true) if (state.wenkaiAvailable) { expect(state.wenkaiLoaded).toBe(true) } await page.screenshot({ path: 'test-results/dashboard-offline.png', fullPage: true, }) }) test('keeps a long run log scrollable without rendering every line', async ({ page, request, }) => { const headers = await authHeaders(request) await request.delete('/api/v1/tasks/e2e_long_log', { headers }) const response = await request.post('/api/v1/tasks', { headers, data: { ...taskPayload, id: 'e2e_long_log', name: 'Long log task', timeout_secs: 60, action: { ...taskPayload.action, args: ['for /L %i in (1,1,1500) do @echo line-%i'], }, }, }) expect(response.status()).toBe(201) await page.goto('/') await page .getByRole('row', { name: /Long log task/ }) .getByRole('button', { name: '运行' }) .click() const drawer = page.getByRole('dialog', { name: 'Long log task' }) await expect(drawer).toBeVisible() await expect(drawer.locator('.status-succeeded').first()).toBeVisible({ timeout: 60_000, }) // 自动跟随到底部,最后几行在视口内。 await expect(drawer.getByText('line-1500')).toBeVisible() // 虚拟滚动:DOM 中只保留视口附近的少量行,而不是 1500 行全量渲染。 const rendered = await drawer.locator('.log-line').count() expect(rendered).toBeLessThan(200) expect(rendered).toBeGreaterThan(5) await drawer.locator('.log-viewer').evaluate((node) => { node.scrollTop = node.scrollHeight / 2 }) const jump = drawer.getByRole('button', { name: '回到最新 ↓' }) await expect(jump).toBeVisible() const logBox = await drawer.locator('.log-viewer').boundingBox() const jumpBox = await jump.boundingBox() expect(logBox).not.toBeNull() expect(jumpBox).not.toBeNull() if (logBox && jumpBox) { // 按钮必须落在日志面板可视范围内,而不是被面板裁剪到外面。 expect(jumpBox.y + jumpBox.height).toBeLessThanOrEqual( logBox.y + logBox.height + 1, ) } await page.screenshot({ path: 'test-results/dashboard-log-drawer-long.png', fullPage: true, }) }) test('manages python package mirrors', async ({ page, request }) => { const headers = await authHeaders(request) // 失败重跑时先清理上一次留下的测试镜像,并把默认源还原。 const existing = await request.get('/api/v1/python/mirrors') expect(existing.ok()).toBeTruthy() const existingMirrors = (await existing.json()) as Array<{ id: string index_url: string }> for (const mirror of existingMirrors) { if (mirror.index_url.includes('pypi.e2e.example')) { await request.delete(`/api/v1/python/mirrors/${mirror.id}`, { headers }) } } await request.post('/api/v1/python/mirrors/tsinghua/default', { headers }) await page.goto('/mirrors') await expect( page.getByRole('heading', { name: 'Python 镜像' }), ).toBeVisible() await expect(page.getByRole('row', { name: /清华 TUNA/ })).toBeVisible() await page.getByRole('button', { name: '+ 新增镜像' }).click() const dialog = page.getByRole('dialog', { name: '新增 Python 镜像' }) await dialog.getByLabel('名称').fill('E2E 镜像') await dialog.getByLabel('索引地址').fill('https://pypi.e2e.example/simple') await dialog.getByRole('button', { name: '保存' }).click() const row = page.getByRole('row', { name: /E2E 镜像/ }) await expect(row).toBeVisible() await row.getByRole('button', { name: '设为默认' }).click() await expect(row.getByText('默认', { exact: true })).toBeVisible() await row.getByRole('button', { name: '删除' }).click() await page .getByRole('dialog', { name: /删除镜像/ }) .getByRole('button', { name: '确认删除' }) .click() await expect(row).toHaveCount(0) await request.post('/api/v1/python/mirrors/tsinghua/default', { headers }) })