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() }) })