182 lines
11 KiB
React
182 lines
11 KiB
React
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()
|
||
})
|
||
})
|