HH-849: gate browser starts on readiness (#25)
This commit is contained in:
@@ -114,7 +114,7 @@ function AccountForm({ busy, onSubmit }) {
|
||||
<TextField select label="凭据引用来源" value={form.credential_provider} onChange={event => update('credential_provider', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用来源' } }}>
|
||||
<MenuItem value="os_keyring">系统密钥环</MenuItem><MenuItem value="secret_manager">Secret Manager</MenuItem>
|
||||
</TextField>
|
||||
<TextField required label="凭据引用键" value={form.credential_key} onChange={event => update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 127 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" />
|
||||
<TextField required label="凭据引用键" value={form.credential_key} onChange={event => update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 192 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" />
|
||||
<Button type="submit" variant="contained" size="large" disabled={!valid || busy} sx={{ minHeight: 52, gridColumn: { md: '1 / -1', lg: 'auto' } }}>{busy ? '创建中…' : '创建账号'}</Button>
|
||||
</Paper>
|
||||
)
|
||||
|
||||
@@ -45,6 +45,27 @@ describe('AccountList', () => {
|
||||
expect(screen.queryByLabelText(/密码|Cookie|token/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts and submits a 192-character credential reference key', async () => {
|
||||
const dataProvider = provider()
|
||||
const credentialKey = `${'a'.repeat(64)}/${'b'.repeat(127)}`
|
||||
renderList(dataProvider)
|
||||
await screen.findAllByText('shop-a')
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '平台' }), { target: { value: 'mock' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '平台账号标识' }), { target: { value: 'shop-new' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '凭据引用 ID' }), { target: { value: 'credential-new' } })
|
||||
const input = screen.getByRole('textbox', { name: '凭据引用键' })
|
||||
fireEvent.change(input, { target: { value: credentialKey } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建账号' }))
|
||||
|
||||
expect(credentialKey).toHaveLength(192)
|
||||
expect(input.maxLength).toBe(192)
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('accounts', { data: {
|
||||
platform: 'mock', platform_account_key: 'shop-new', authorization_kind: 'owned',
|
||||
credential_reference: { id: 'credential-new', provider: 'os_keyring', key: credentialKey },
|
||||
} }))
|
||||
})
|
||||
|
||||
it('distinguishes resumable bindings from missing resources', () => {
|
||||
expect(accountReadiness(account, binding)).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
|
||||
expect(accountReadiness(account, undefined)).toMatchObject({ label: '未绑定运行环境', canResume: false })
|
||||
|
||||
@@ -60,6 +60,8 @@ const scheduleReasonLabels = {
|
||||
runtime_missing: '运行实例缺失',
|
||||
}
|
||||
|
||||
const scheduleBlockLabel = runtime => runtime.schedule_status === 'ready' ? '' : (scheduleReasonLabels[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度')
|
||||
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
alias: '',
|
||||
@@ -106,9 +108,10 @@ function Status({ state }) {
|
||||
|
||||
function RuntimeActions({ runtime, busy, onAction }) {
|
||||
const running = runtime.state === 'running'
|
||||
const startBlockReason = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlockLabel(runtime)
|
||||
return (
|
||||
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>
|
||||
<Button aria-label={`启动 ${runtime.name}`} variant="outlined" size="small" disabled={busy || running} startIcon={<PlayArrowOutlined />} onClick={() => onAction(runtime, 'start')}>启动</Button>
|
||||
<Button aria-label={`启动 ${runtime.name}${startBlockReason ? `(${startBlockReason})` : ''}`} variant="outlined" size="small" disabled={busy || running || startBlockReason !== ''} startIcon={<PlayArrowOutlined />} onClick={() => onAction(runtime, 'start')}>启动</Button>
|
||||
<Button aria-label={`停止 ${runtime.name}`} variant="outlined" color="warning" size="small" disabled={busy || !running} startIcon={<StopOutlined />} onClick={() => onAction(runtime, 'stop')}>停止</Button>
|
||||
<Button aria-label={`升级 ${runtime.name}`} variant="outlined" size="small" disabled={busy} startIcon={<UpgradeOutlined />} onClick={() => onAction(runtime, 'upgrade')}>升级</Button>
|
||||
<Button aria-label={`回收 ${runtime.name}`} variant="outlined" color="error" size="small" disabled={busy} startIcon={<DeleteOutlined />} onClick={() => onAction(runtime, 'recycle')}>回收</Button>
|
||||
@@ -258,9 +261,8 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
|
||||
}
|
||||
|
||||
function BindingReadiness({ runtime }) {
|
||||
const ready = runtime.schedule_status === 'ready'
|
||||
const reason = runtime.schedule_block_reason
|
||||
return <Stack direction="row" spacing={0.75} sx={{ alignItems: 'center', color: ready ? 'success.main' : 'warning.main' }}>{ready ? <CheckCircleOutlined fontSize="small" /> : <ReportProblemOutlined fontSize="small" />}<Typography variant="caption" fontWeight={650}>{ready ? '可调度' : (scheduleReasonLabels[reason] || reason || '不可调度')}</Typography></Stack>
|
||||
const blockReason = scheduleBlockLabel(runtime)
|
||||
return <Stack direction="row" spacing={0.75} sx={{ alignItems: 'center', color: blockReason ? 'warning.main' : 'success.main' }}>{blockReason ? <ReportProblemOutlined fontSize="small" /> : <CheckCircleOutlined fontSize="small" />}<Typography variant="caption" fontWeight={650}>{blockReason || '可调度'}</Typography></Stack>
|
||||
}
|
||||
|
||||
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
|
||||
|
||||
@@ -81,6 +81,44 @@ describe('BrowserList', () => {
|
||||
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>)
|
||||
|
||||
Reference in New Issue
Block a user