Files
creator-hub/web/src/BrowserList.test.jsx
T

182 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { CoreAdminContext, HttpError } from 'ra-core'
import { BrowserList } from './BrowserList'
import { theme } from './theme'
afterEach(cleanup)
const runtimes = [
{ id: 'account-a', alias: 'account-a', name: '店铺一号', state: 'running', status: 'Up', image_version: '148.0.0.1', fingerprint: { seed: 1000 }, endpoint: 'http://account-a:9222', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'ready' },
{ id: 'account-b', alias: 'account-b', name: '店铺二号', state: 'exited', status: 'Exited', image_version: '148.0.0.1', fingerprint: { seed: 2000 }, endpoint: 'http://account-b:9222' },
]
const gateways = [{ id: 'gw-1', name: 'gw-1', endpoint: 'http://gw:8081', token: 't' }]
const images = [
{ id: '148.0.0.1', version: '148.0.0.1', image_ref: 'reg/img:148', enabled: true },
{ id: '144.0.0.1', version: '144.0.0.1', image_ref: 'reg/img:144', enabled: true },
{ id: '139.0.0.1', version: '139.0.0.1', image_ref: 'reg/img:139', enabled: false },
]
const accounts = [{ id: 'social-a', platform: 'douyin', authorization_status: 'authorized', runtime_status: 'paused' }]
const networkExits = [{ id: 'exit-1', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy' }]
function provider(overrides = {}) {
return {
getList: vi.fn((resource) => {
if (resource === 'browsers') return Promise.resolve({ data: runtimes, total: runtimes.length })
if (resource === 'gateways') return Promise.resolve({ data: gateways, total: gateways.length })
if (resource === 'browser-images') return Promise.resolve({ data: images, total: images.length })
if (resource === 'accounts') return Promise.resolve({ data: accounts, total: accounts.length })
if (resource === 'network-exits') return Promise.resolve({ data: networkExits, total: networkExits.length })
return Promise.reject(new Error(`unsupported ${resource}`))
}),
create: vi.fn().mockResolvedValue({ data: { id: 'account-a', alias: 'account-a' } }),
browserAction: vi.fn().mockResolvedValue(undefined),
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
...overrides,
}
}
describe('BrowserList', () => {
it('uses the semantic design colors', () => {
expect(theme.palette.text.secondary).toBe('#475467')
expect(theme.palette.success.main).toBe('#067647')
expect(theme.palette.warning.main).toBe('#B54708')
expect(theme.palette.error.main).toBe('#B42318')
})
it('keeps CDP data in desktop and mobile views', async () => {
render(<CoreAdminContext dataProvider={provider()}><BrowserList /></CoreAdminContext>)
expect(await screen.findAllByText('http://account-a:9222')).toHaveLength(2)
expect(screen.getAllByText('CDP')).toHaveLength(1)
})
it('shows unknown states with their raw value as secondary evidence', async () => {
const unknownRuntime = { ...runtimes[0], state: 'dead' }
render(<CoreAdminContext dataProvider={provider({ getList: vi.fn().mockResolvedValue({ data: [unknownRuntime], total: 1 }) })}><BrowserList /></CoreAdminContext>)
expect(await screen.findAllByText('未知状态')).toHaveLength(2)
const rawStates = screen.getAllByText('dead')
expect(rawStates).toHaveLength(2)
expect(rawStates[0].className).toContain('MuiTypography-caption')
})
it.each(['constructor', 'toString', '__proto__'])('treats prototype key %s as an unknown state', async (state) => {
const protoRuntime = { ...runtimes[0], state }
render(<CoreAdminContext dataProvider={provider({ getList: vi.fn().mockResolvedValue({ data: [protoRuntime], total: 1 }) })}><BrowserList /></CoreAdminContext>)
expect(await screen.findAllByText('未知状态')).toHaveLength(2)
expect(screen.getAllByText(state)).toHaveLength(2)
})
it('disables invalid lifecycle actions and sends explicit domain actions', async () => {
const dataProvider = provider()
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
await screen.findAllByText('店铺一号')
expect(screen.getAllByLabelText('启动 店铺一号')[0].disabled).toBe(true)
expect(screen.getAllByLabelText('停止 店铺二号')[0].disabled).toBe(true)
fireEvent.click(screen.getAllByLabelText('停止 店铺一号')[0])
await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop', undefined))
})
it('starts ready or runtime-missing environments and preserves resource gates', async () => {
const environments = [
{ ...runtimes[1], id: 'ready', alias: 'ready', name: '就绪环境', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'ready' },
{ ...runtimes[1], id: 'stopped', alias: 'stopped', name: '已停止环境', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'blocked', schedule_block_reason: 'runtime_missing' },
{ ...runtimes[1], id: 'revoked', alias: 'revoked', name: '已撤权环境', schedule_status: 'blocked', schedule_block_reason: 'account_revoked' },
{ ...runtimes[1], id: 'paused', alias: 'paused', name: '账号暂停环境', schedule_status: 'blocked', schedule_block_reason: 'account_paused' },
{ ...runtimes[1], id: 'unbound', alias: 'unbound', name: '未绑定环境', schedule_status: 'blocked', schedule_block_reason: 'binding_missing' },
{ ...runtimes[1], id: 'no-exit', alias: 'no-exit', name: '无出口环境', schedule_status: 'blocked', schedule_block_reason: 'network_exit_missing' },
{ ...runtimes[1], id: 'unhealthy', alias: 'unhealthy', name: '出口异常环境', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'unhealthy', schedule_status: 'blocked', schedule_block_reason: 'network_exit_unhealthy' },
{ ...runtimes[1], id: 'cleanup', alias: 'cleanup', name: '清理中环境', schedule_status: 'blocked', schedule_block_reason: 'runtime_stop_pending' },
]
const dataProvider = provider({ getList: vi.fn(resource => Promise.resolve({ data: resource === 'browsers' ? environments : [], total: resource === 'browsers' ? environments.length : 0 })) })
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
fireEvent.click((await screen.findAllByLabelText('启动 就绪环境'))[0])
fireEvent.click(screen.getAllByLabelText('启动 已停止环境')[0])
for (const [name, reason] of [['已撤权环境', '授权已撤销'], ['账号暂停环境', '账号已暂停'], ['未绑定环境', '绑定缺失'], ['无出口环境', '固定出口缺失'], ['出口异常环境', '固定出口不健康'], ['清理中环境', '停止结果待确认']]) {
expect(screen.getAllByLabelText(`启动 ${name}${reason}`).every(button => button.disabled)).toBe(true)
}
await waitFor(() => expect(dataProvider.browserAction.mock.calls).toEqual(expect.arrayContaining([
['ready', 'start', undefined],
['stopped', 'start', undefined],
])))
})
it('keeps the backend 409 fallback for stale readiness', async () => {
const stale = { ...runtimes[1], account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'ready' }
const dataProvider = provider({
getList: vi.fn(resource => Promise.resolve({ data: resource === 'browsers' ? [stale] : [], total: resource === 'browsers' ? 1 : 0 })),
browserAction: vi.fn().mockRejectedValue(new HttpError('readiness changed', 409)),
})
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
fireEvent.click((await screen.findAllByLabelText('启动 店铺二号'))[0])
expect((await screen.findByRole('alert')).textContent).toContain('readiness changed')
})
it('creates an env with an account and healthy network exit without legacy proxy fields', async () => {
const dataProvider = provider()
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
await screen.findAllByText('店铺一号')
fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '店铺三号' } })
fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'shop-03' } })
fireEvent.click(screen.getByRole('button', { name: '创建环境' }))
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: {
alias: 'shop-03',
name: '店铺三号',
gateway: 'gw-1',
image_version: '148.0.0.1',
account_id: 'social-a',
network_exit_id: 'exit-1',
fingerprint: { seed: 1000 },
} }))
expect(screen.queryByLabelText('代理 proxy-server')).toBeNull()
expect(screen.queryByLabelText('禁用非代理 UDP')).toBeNull()
})
it('keeps binding input after a 409 conflict', async () => {
const dataProvider = provider({ create: vi.fn().mockRejectedValue(new HttpError('conflict', 409)) })
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
await screen.findAllByText('店铺一号')
fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '冲突环境' } })
fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'conflict-env' } })
fireEvent.click(screen.getByRole('button', { name: '创建环境' }))
expect((await screen.findByRole('alert')).textContent).toContain('冲突(409')
expect(screen.getByRole('textbox', { name: /环境名称/ }).value).toBe('冲突环境')
})
it('upgrades an env through the version dialog', async () => {
const dataProvider = provider()
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
await screen.findAllByText('店铺一号')
fireEvent.click(screen.getAllByLabelText('升级 店铺一号')[0])
const dialog = await screen.findByRole('dialog')
fireEvent.mouseDown(within(dialog).getByRole('combobox', { name: /目标镜像版本/ }))
fireEvent.click(await within(document.body).findByRole('option', { name: '144.0.0.1' }))
fireEvent.click(within(dialog).getByRole('button', { name: '确认升级' }))
await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'upgrade', { version: '144.0.0.1' }))
})
it('shows action errors and keeps a retry control', async () => {
const dataProvider = provider({ browserAction: vi.fn().mockRejectedValue(new Error('停止失败')) })
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
await screen.findAllByText('店铺一号')
fireEvent.click(screen.getAllByLabelText('停止 店铺一号')[0])
expect((await screen.findByRole('alert')).textContent).toContain('停止失败')
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy()
})
})