- 任务类型改为「执行脚本 / 发起 HTTP 请求 / 运行二进制程序」,脚本类型下新增语言选择(Python / Shell / Node / Lua) - 语言联动表单:Python 才显示虚拟环境、解释器和 uv 状态;Shell/Node/Lua 隐藏这些字段并切换脚本标签、占位符和调用预览 - 切换语言时自动设置解释器与默认脚本,Shell 通过 cmd.exe /C 执行批处理,Node/Lua 使用对应解释器 - TaskAction 新增可选 language,旧任务按解释器推断;补 Shell 执行测试、语言切换单测与 Playwright 用例
410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
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('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 })
|
|
})
|