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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
await screen.findAllByText('店铺一号')
fireEvent.click(screen.getAllByLabelText('停止 店铺一号')[0])
expect((await screen.findByRole('alert')).textContent).toContain('停止失败')
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy()
})
})