Files
creator-hub/web/tests/responsive.e2e.js
T
rogee 7ecad93821 refactor(web): migrate frontend to React 19 + Refine v5 + shadcn/ui stack
技术栈切换(按 AGENTS.md 批准栈):react-admin/MUI → Refine v5 + shadcn/ui
(Tailwind CSS v4),Vite 8 + React 19 + react-router v7 hash 路由。

- 全部 9 个页面重写:AccountsPage/BrowsersPage(BrowserList/CreatePage/Detail)/
  BrowserImagesPage/GatewaysPage/NetworkExitsPage/TasksPage/TracePages/
  DraftPage/AuditPage;自建 lib/ui.jsx 组件库与 Layout(侧导航 + Outlet)
- 简单创建/编辑改 modal(账号/网关/镜像/出口),环境创建走独立页
  /browsers/new;列表按信息密度选表格或移动卡片双形态
- 图标统一 RemixIcon;领域动作保持显式动词(start/stop/upgrade/recycle
  等,不伪装成 CRUD update)
- LoginPage:Basic 凭证存 localStorage;401 清凭证并跳 #/login;
  AuthGate 改为渲染期读 localStorage(react-router v7 的 pushState 不触发
  hashchange,事件订阅会在导航后拿到过期状态导致登录后卡死)
- dataProvider 适配 Refine v5 契约:所有方法收单个参数对象
  ({resource, pagination, filters, meta, variables}),create/update/
  delete/deleteOne 语义对齐
- Modal 关闭时焦点还给触发元素;Select 保持原生 button/combobox 语义
- e2e 补登录前置;账号创建流程同步 modal 交互

验证:npm test 65/65 通过;覆盖率 71.5% stmts / 76.3% lines(门槛 65%);
vite build 成功;playwright e2e 9/9 通过。
2026-09-02 13:40:34 +08:00

267 lines
16 KiB
JavaScript

import { expect, test } from '@playwright/test'
const runtime = {
id: 'container-a',
alias: 'account-a',
name: 'account-a',
state: 'running',
status: 'Up',
image_version: '148.0.0.1',
fingerprint: { seed: 1000 },
profile: 'creatorhub-profile-account-a',
endpoint: 'http://account-a:9222',
}
// 应用带登录门:直接预置 Basic 凭证,走与真实登录等价的 localStorage 路径
const credential = 'user:pass'
test.beforeEach(async ({ page }) => {
await page.addInitScript(credential => localStorage.setItem('creatorhub.auth', credential), credential)
})
test('opens social-account and network-exit configuration from the main menu', async ({ page }) => {
await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
await page.route('**/api/gateways', route => route.fulfill({ json: [] }))
await page.route('**/api/browser-images', route => route.fulfill({ json: [] }))
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [] }))
await page.route('**/api/network-exits', route => route.fulfill({ json: [] }))
await page.setViewportSize({ width: 599, height: 800 })
await page.goto('/#/browsers')
await page.getByRole('button', { name: '打开导航' }).click()
await page.getByRole('link', { name: '社媒账号' }).click()
await expect(page).toHaveURL(/#\/accounts$/)
await expect(page.getByRole('heading', { level: 1, name: '社媒账号' })).toBeVisible()
await page.getByRole('button', { name: '打开导航' }).click()
await page.getByRole('link', { name: '网络出口' }).click()
await expect(page).toHaveURL(/#\/network-exits$/)
await expect(page.getByRole('heading', { level: 1, name: '网络出口' })).toBeVisible()
})
test('keeps the create form inside a 900px viewport', async ({ page }) => {
await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
await page.route('**/api/gateways', route => route.fulfill({ json: [] }))
await page.route('**/api/browser-images', route => route.fulfill({ json: [] }))
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [] }))
await page.route('**/api/network-exits', route => route.fulfill({ json: [] }))
await page.setViewportSize({ width: 900, height: 800 })
await page.goto('/#/browsers')
await expect(page.getByRole('button', { name: '收起导航' }).first()).toBeVisible()
const createButton = page.getByRole('button', { name: '创建环境' })
await expect(createButton).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(900)
const box = await createButton.boundingBox()
expect(box).not.toBeNull()
expect(box.x + box.width).toBeLessThanOrEqual(900)
})
test('shows the CDP endpoint only in the active branch at 900px and 599px', async ({ page }) => {
await page.route('**/api/browsers', route => route.fulfill({ json: [runtime] }))
await page.route('**/api/gateways', route => route.fulfill({ json: [] }))
await page.route('**/api/browser-images', route => route.fulfill({ json: [] }))
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [] }))
await page.route('**/api/network-exits', route => route.fulfill({ json: [] }))
const endpoint = page.getByText('http://account-a:9222')
await page.setViewportSize({ width: 900, height: 800 })
await page.goto('/#/browsers')
await expect(page.getByRole('cell', { name: 'http://account-a:9222' })).toBeVisible()
await expect(endpoint.filter({ visible: true })).toHaveCount(1)
await page.setViewportSize({ width: 599, height: 800 })
await expect(page.getByRole('cell', { name: 'http://account-a:9222' })).toBeHidden()
await expect(endpoint.filter({ visible: true })).toHaveCount(1)
})
test('enforces browser readiness before starting at 900px', async ({ page }) => {
const stopped = { ...runtime, state: 'exited', status: 'Exited', endpoint: '' }
const environments = [
{ ...stopped, id: 'ready', alias: 'ready', name: '就绪环境', schedule_status: 'ready' },
{ ...stopped, id: 'missing', alias: 'missing', name: '实例缺失环境', schedule_status: 'blocked', schedule_block_reason: 'runtime_missing' },
{ ...stopped, id: 'revoked', alias: 'revoked', name: '已撤权环境', schedule_status: 'blocked', schedule_block_reason: 'account_revoked' },
]
await page.route('**/api/browsers', route => route.fulfill({ json: environments }))
await page.route('**/api/gateways', route => route.fulfill({ json: [] }))
await page.route('**/api/browser-images', route => route.fulfill({ json: [] }))
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [] }))
await page.route('**/api/network-exits', route => route.fulfill({ json: [] }))
await page.route('**/api/browsers/*/start', route => route.fulfill({ status: 204 }))
await page.setViewportSize({ width: 900, height: 900 })
await page.goto('/#/browsers')
const ready = page.getByRole('button', { name: '启动 就绪环境' })
const missing = page.getByRole('button', { name: '启动 实例缺失环境' })
await expect(ready).toBeEnabled()
await expect(missing).toBeEnabled()
await expect(page.getByRole('button', { name: '启动 已撤权环境(授权已撤销)' })).toBeDisabled()
for (const [button, alias] of [[ready, 'ready'], [missing, 'missing']]) {
const request = page.waitForRequest(`**/api/browsers/${alias}/start`)
await button.click()
expect((await request).method()).toBe('POST')
}
})
test('submits the minimal social-account form at 900px', async ({ page }) => {
await page.route('**/api/phase-a/accounts', async route => {
if (route.request().method() === 'POST') return route.fulfill({ json: { id: 'account-new' } })
return route.fulfill({ json: [] })
})
await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
await page.setViewportSize({ width: 900, height: 900 })
await page.goto('/#/accounts')
// 简单创建走弹窗:先打开 modal 再填写
await page.getByRole('button', { name: '创建账号' }).click()
const dialog = page.getByRole('dialog')
await dialog.getByRole('textbox', { name: '账号名称', exact: true }).fill('店铺一号')
await dialog.getByRole('combobox', { name: '平台类型', exact: true }).click()
await page.getByRole('option', { name: '抖音', exact: true }).click()
await dialog.getByRole('textbox', { name: '账号 ID', exact: true }).fill('shop-new')
await dialog.getByRole('textbox', { name: 'TAGS', exact: true }).fill('主账号,直播')
await dialog.getByRole('textbox', { name: 'Cookies', exact: true }).fill('sessionid=value; token=second')
const request = page.waitForRequest(request => request.url().endsWith('/api/phase-a/accounts') && request.method() === 'POST')
await dialog.getByRole('button', { name: '创建账号' }).click()
expect((await request).postDataJSON()).toEqual({ name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-new', tags: ['主账号', '直播'], cookies: 'sessionid=value; token=second' })
})
test('keeps account and network-exit pages inside 599px, 900px and 1280px', async ({ page }) => {
const accountKey = 'a'.repeat(128)
const credentialID = 'c'.repeat(128)
const hostname = [63, 63, 63, 61].map(length => 'h'.repeat(length)).join('.')
const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: accountKey, tags: ['主账号'], authorization_status: 'authorized', runtime_status: 'paused', version: 1 }
const binding = { alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 1, schedule_status: 'blocked', schedule_block_reason: 'account_paused' }
const networkExit = { id: 'exit-a', protocol: 'socks5', host: hostname, port: 1080, health_status: 'healthy', credential_reference: { id: credentialID, provider: 'os_keyring' } }
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] }))
await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account }))
await page.route('**/api/phase-a/drafts?account_id=account-a', route => route.fulfill({ json: [] }))
await page.route('**/api/browsers', route => route.fulfill({ json: [binding] }))
await page.route('**/api/network-exits', route => route.fulfill({ json: [networkExit] }))
for (const path of ['/#/accounts', '/#/accounts/account-a', '/#/network-exits']) {
for (const width of [599, 900, 1280]) {
await page.setViewportSize({ width, height: 900 })
await page.goto(path)
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
}
}
})
test('opens account detail at the phase A route', async ({ page }) => {
const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: [], authorization_status: 'authorized', runtime_status: 'paused', version: 1 }
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] }))
await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account }))
await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
await page.route('**/api/phase-a/drafts?account_id=account-a', route => route.fulfill({ json: [] }))
await page.setViewportSize({ width: 599, height: 900 })
await page.goto('/#/accounts')
await page.getByRole('link', { name: '查看账号' }).click()
await expect(page).toHaveURL(/#\/accounts\/account-a$/)
await expect(page.getByRole('heading', { level: 1, name: '店铺一号' })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(599)
})
test('keeps draft review responsive and restores focus after dialog close and successful save', async ({ page }) => {
const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: [], authorization_status: 'authorized', runtime_status: 'active', version: 2 }
const draft = {
id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000), account,
versions: [{ id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000) }],
confirmations: [], tasks: [],
}
const binding = { alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 4, schedule_status: 'ready', schedule_block_reason: '' }
await page.route('**/api/phase-a/drafts/draft-a', route => route.fulfill({ json: draft }))
await page.route('**/api/browsers', route => route.fulfill({ json: [binding] }))
await page.route('**/api/phase-a/confirmations', route => route.fulfill({ json: { id: 'confirmation-a' } }))
for (const width of [599, 900, 1280]) {
await page.setViewportSize({ width, height: 900 })
await page.goto('/#/drafts/draft-a')
await expect(page.getByRole('heading', { level: 1, name: '草稿核对' })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
}
await page.setViewportSize({ width: 599, height: 900 })
await page.getByRole('checkbox', { name: /我已核对当前账号/ }).check()
const trigger = page.getByRole('button', { name: '确认当前快照' })
await trigger.click()
await expect(page.getByRole('dialog', { name: '确认草稿版本' })).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.click()
await page.getByRole('button', { name: '保存确认' }).click()
await expect(page.getByRole('dialog')).toBeHidden()
await expect(trigger).toBeFocused()
})
test('keeps task recovery and audit traceable without blind retry at 599px, 900px and 1280px', async ({ page }) => {
let verified = false
let resumed = false
let auditPage = 0
const baseTask = {
id: 'task-a', account_id: 'account-a', draft_id: 'draft-a', confirmation_id: 'confirmation-a', state: 'needs_confirmation',
hold_reason: 'task_result_uncertain', browser_env_alias: 'env-a', network_exit_id: 'exit-a', runtime_instance_id: 'runtime-a', binding_version: 4,
confirmation: { id: 'confirmation-a', version: 1, account_version: 2, draft_version: 1, browser_env_alias: 'env-a', network_exit_id: 'exit-a', runtime_instance_id: 'runtime-a', binding_version: 4 },
attempts: [{ id: 'attempt-a', started_at: '2026-08-30T00:00:00Z', outcome: 'uncertain', evidence: { mock_outcome: 'uncertain' } }],
}
const listTask = { ...baseTask, confirmation: undefined, attempts: undefined, allowed_action: undefined, updated_at: '2026-08-30T00:01:00Z' }
await page.route(/\/api\/phase-a\/tasks(?:\/.*)?(?:\?.*)?$/, async route => {
const request = route.request()
const url = new URL(request.url())
if (request.method() === 'POST' && url.pathname.endsWith('/verify')) {
expect(request.postDataJSON()).toEqual({ result: 'not_executed' })
verified = true
return route.fulfill({ status: 204 })
}
if (request.method() === 'POST' && url.pathname.endsWith('/resume')) {
resumed = true
return route.fulfill({ status: 204 })
}
if (url.pathname.endsWith('/task-a')) return route.fulfill({ json: { ...baseTask, state: resumed ? 'queued' : baseTask.state, allowed_action: resumed ? '' : verified ? 'resume' : 'verify', verification_result: verified ? 'not_executed' : undefined } })
return route.fulfill({ json: [listTask] })
})
await page.route('**/api/phase-a/attempts/attempt-a', route => route.fulfill({ json: { ...baseTask.attempts[0], task_id: 'task-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4 } }))
await page.route('**/api/browsers/env-a', route => route.fulfill({ json: { alias: 'env-a', name: '店铺环境', account_id: 'account-a', image_version: '148', binding_version: 4, runtime_instance_id: 'runtime-a', network_exit: { id: 'exit-a', health_status: 'healthy' } } }))
await page.route('**/api/network-exits/exit-a', route => route.fulfill({ json: { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy', version: 2 } }))
await page.route('**/api/phase-a/audit*', route => {
const currentPage = Number(new URL(route.request().url()).searchParams.get('page') || 1)
auditPage = Math.max(auditPage, currentPage)
return route.fulfill({ json: { data: [{
id: 7, event_type: 'task_verified', account_id: 'account-a', task_id: 'task-a', attempt_id: 'attempt-a', browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4, reason_code: 'manual_verification_recorded', created_at: '2026-08-30T00:01:00Z',
}], total: 26, page: currentPage, page_size: 25 } })
})
for (const path of ['/#/tasks', '/#/tasks/task-a', '/#/attempts/attempt-a', '/#/browsers/env-a', '/#/network-exits/exit-a', '/#/audit?task_id=task-a']) {
for (const width of [599, 900, 1280]) {
await page.setViewportSize({ width, height: 900 })
await page.goto(path)
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
}
}
verified = false
await page.setViewportSize({ width: 599, height: 900 })
await page.goto('/#/tasks/task-a')
await expect(page.getByRole('button', { name: /恢复排队/ })).toHaveCount(0)
await page.getByRole('button', { name: '记录人工核验' }).click()
await expect(page.getByRole('button', { name: '按核验结论恢复排队' })).toBeVisible()
await page.getByRole('button', { name: '按核验结论恢复排队' }).click()
await expect.poll(() => resumed).toBe(true)
await expect(page.getByText('已排队')).toBeVisible()
await page.getByRole('link', { name: '查看关联审计' }).click()
await expect(page).toHaveURL(/#\/audit\?task_id=task-a$/)
await expect(page.getByRole('link', { name: 'task-a' }).first()).toBeVisible()
await page.getByRole('button', { name: '下一页' }).click()
await expect.poll(() => auditPage).toBe(2)
await page.getByRole('link', { name: 'attempt-a' }).first().click()
await expect(page).toHaveURL(/#\/attempts\/attempt-a$/)
await expect(page.getByRole('link', { name: '返回任务' })).toHaveAttribute('href', '#/tasks/task-a')
})