From f6909376e149d7f3afa3135af84ce9e4bf7048a2 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 31 Aug 2026 09:16:07 +0800 Subject: [PATCH] HH-849: gate browser starts on readiness (#25) --- web/src/AccountList.jsx | 2 +- web/src/AccountList.test.jsx | 21 ++++++++++++++++++++ web/src/BrowserList.jsx | 10 ++++++---- web/src/BrowserList.test.jsx | 38 ++++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/web/src/AccountList.jsx b/web/src/AccountList.jsx index 3d3854e..efe8abf 100644 --- a/web/src/AccountList.jsx +++ b/web/src/AccountList.jsx @@ -114,7 +114,7 @@ function AccountForm({ busy, onSubmit }) { update('credential_provider', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用来源' } }}> 系统密钥环Secret Manager - update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 127 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" /> + update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 192 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" /> ) diff --git a/web/src/AccountList.test.jsx b/web/src/AccountList.test.jsx index a9b7b21..1606ea5 100644 --- a/web/src/AccountList.test.jsx +++ b/web/src/AccountList.test.jsx @@ -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 }) diff --git a/web/src/BrowserList.jsx b/web/src/BrowserList.jsx index 09844d1..c2ebf4e 100644 --- a/web/src/BrowserList.jsx +++ b/web/src/BrowserList.jsx @@ -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 ( - + @@ -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 {ready ? : }{ready ? '可调度' : (scheduleReasonLabels[reason] || reason || '不可调度')} + const blockReason = scheduleBlockLabel(runtime) + return {blockReason ? : }{blockReason || '可调度'} } function UpgradeDialog({ target, images, busy, onClose, onConfirm }) { diff --git a/web/src/BrowserList.test.jsx b/web/src/BrowserList.test.jsx index 16db3e4..443a8f3 100644 --- a/web/src/BrowserList.test.jsx +++ b/web/src/BrowserList.test.jsx @@ -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() + + 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()