feat(robot): add guided hotspot binding flow

This commit is contained in:
brother7 committed 2026-08-16 14:27:43 +08:00
1 parent 54443232dd
commit b7a1590ca1
7 files changed
+855 -18

No files matched your search

+219 -6
View File
@@ -10,6 +10,8 @@ import {
const api = vi.hoisted(() => ({
getAiHardwareOverview: vi.fn(),
getAiHardwareProvisioningCapabilities: vi.fn(),
openAiHardwareProvisioningPortal: vi.fn(),
recoverAiHardwareCredential: vi.fn(),
createAiHardwareAgent: vi.fn(),
bindAiHardwareDevice: vi.fn(),
@@ -88,6 +90,8 @@ async function openConfigurationEditor(): Promise<void> {
describe('AI hardware page', () => {
beforeEach(() => {
api.getAiHardwareOverview.mockResolvedValue(activeOverview);
api.getAiHardwareProvisioningCapabilities.mockResolvedValue({ guidedHotspotBinding: false });
api.openAiHardwareProvisioningPortal.mockResolvedValue({ opened: true });
api.recoverAiHardwareCredential.mockResolvedValue({ status: 'active', agents: [], devices: [] });
api.getAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 4 });
api.getAiHardwareConfigurationCatalog.mockResolvedValue(catalog);
@@ -160,11 +164,144 @@ describe('AI hardware page', () => {
fireEvent.change(input, { target: { value: secret } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledWith(secret, agentOne.id));
await waitFor(() => expect(input).toHaveValue(''));
await waitFor(() => expect(screen.getByLabelText('6 位激活码')).toHaveValue(''));
expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('激活码无效');
});
it('keeps the existing direct-code binding experience when guided provisioning is disabled', async () => {
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '开始引导配网' })).not.toBeInTheDocument();
expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled();
});
it('guides the existing hotspot flow, locks navigation while binding, and avoids online claims', async () => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
const binding = deferred<typeof device>();
api.bindAiHardwareDevice.mockReturnValueOnce(binding.promise);
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' }));
expect(screen.getByText(/设备热点没有加密保护/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
expect(screen.getByText(/Xiaozhi-\*/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
expect(screen.getByText(/Makelore 不读取或保存/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
await waitFor(() => expect(api.openAiHardwareProvisioningPortal).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' }));
expect(screen.getByText(/不会撤销机器人已经保存的 Wi-Fi 设置/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '电脑已恢复联网' }));
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
expect(await screen.findByRole('heading', { name: '正在绑定设备' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '上一步' })).not.toBeInTheDocument();
binding.resolve(device);
expect(await screen.findByRole('heading', { name: '绑定成功' })).toBeInTheDocument();
expect(screen.getByText('绑定成功不代表设备已经上线,请等待机器人完成连接。')).toBeInTheDocument();
expect(screen.queryByText(/设备已就绪/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '完成' }));
await waitFor(() => expect(screen.queryByRole('heading', { name: '绑定成功' })).not.toBeInTheDocument());
});
it('supports every legal guided back transition and the direct-path return to the chooser', async () => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
fireEvent.click(screen.getByRole('button', { name: '我已有 6 位激活码' }));
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '连接设备热点' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
fireEvent.click(await screen.findByRole('button', { name: '我已完成设备配网' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' }));
fireEvent.click(screen.getByRole('button', { name: '电脑已恢复联网' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '恢复电脑网络' })).toBeInTheDocument();
});
it('cancels before portal handoff and starts a fresh chooser state', async () => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '取消' }));
fireEvent.click(screen.getByRole('button', { name: '绑定设备' }));
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled();
});
it('shows a safe portal error and clears guided state when cancelled after portal handoff', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
api.openAiHardwareProvisioningPortal
.mockRejectedValueOnce(new AiHardwareApiError({
status: 502,
code: 'AI_HARDWARE_PORTAL_OPEN_FAILED',
message: 'native secret path',
}))
.mockResolvedValueOnce({ opened: true });
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
expect(await screen.findByRole('alert')).toHaveTextContent('无法打开设备配网页面');
expect(screen.queryByText(/native secret path/)).not.toBeInTheDocument();
expect(screen.getByText('http://192.168.4.1/')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '复制地址' }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith('http://192.168.4.1/'));
expect(screen.getByRole('status')).toHaveTextContent('固定地址已复制');
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
await waitFor(() => expect(api.openAiHardwareProvisioningPortal).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' }));
fireEvent.click(screen.getByRole('button', { name: '取消' }));
fireEvent.click(screen.getByRole('button', { name: '绑定设备' }));
expect(screen.getByRole('button', { name: '开始引导配网' })).toBeInTheDocument();
expect(screen.queryByText(/不会撤销机器人已经保存的 Wi-Fi 设置/)).not.toBeInTheDocument();
});
it('sends cleared nullable configuration fields through clear_fields and never sends null', async () => {
render(<AiHardware />);
await openConfigurationEditor();
@@ -366,6 +503,26 @@ describe('AI hardware page', () => {
expect(await screen.findByRole('alert')).toHaveTextContent('7 秒后重试');
});
it.each([
['ai_hardware_device_already_bound', '设备已被绑定'],
['ai_hardware_state_conflict', '设备状态已变化'],
])('refreshes overview after the binding result %s', async (code, message) => {
api.bindAiHardwareDevice.mockRejectedValueOnce(new AiHardwareApiError({
status: 409,
code,
message: 'private upstream state',
}));
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
expect(await screen.findByRole('alert')).toHaveTextContent(message);
await waitFor(() => expect(api.getAiHardwareOverview).toHaveBeenCalledTimes(2));
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
expect(screen.queryByText(/private upstream state/)).not.toBeInTheDocument();
});
it('reuses the server operation id only when retrying the same binding body', async () => {
api.bindAiHardwareDevice
.mockRejectedValueOnce(new AiHardwareApiError({
@@ -378,8 +535,9 @@ describe('AI hardware page', () => {
const input = screen.getByLabelText('6 位激活码');
fireEvent.change(input, { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await waitFor(() => expect(input).toHaveValue(''));
fireEvent.change(input, { target: { value: '031425' } });
const retryInput = await screen.findByLabelText('6 位激活码');
expect(retryInput).toHaveValue('');
fireEvent.change(retryInput, { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2));
expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual([
@@ -387,6 +545,34 @@ describe('AI hardware page', () => {
]);
});
it('refreshes overview on remount and never reuses a binding operation identity across restart', async () => {
const operationId = '123e4567-e89b-42d3-a456-426614174000';
api.bindAiHardwareDevice
.mockRejectedValueOnce(new AiHardwareApiError({
status: 409,
code: 'ai_hardware_operation_in_progress',
message: 'private',
retryable: true,
operationId,
}))
.mockResolvedValueOnce(device);
const first = render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await screen.findByLabelText('6 位激活码');
first.unmount();
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '654321' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2));
expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['654321', agentOne.id]);
await waitFor(() => expect(api.getAiHardwareOverview).toHaveBeenCalledTimes(3));
});
it('does not retain an activation code and starts a fresh operation for a different code', async () => {
const operationId = '123e4567-e89b-42d3-a456-426614174000';
api.bindAiHardwareDevice
@@ -401,17 +587,44 @@ describe('AI hardware page', () => {
fireEvent.change(input, { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await waitFor(() => expect(input).toHaveValue(''));
const nextInput = await screen.findByLabelText('6 位激活码');
expect(nextInput).toHaveValue('');
expect(screen.queryByText(/031425/)).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: '654321' } });
fireEvent.change(nextInput, { target: { value: '654321' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2));
expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['654321', agentOne.id]);
await waitFor(() => expect(input).toHaveValue(''));
await screen.findByRole('heading', { name: '绑定成功' });
expect(screen.queryByText(/031425|654321/)).not.toBeInTheDocument();
});
it('drops an invalid-code operation identity even if the error is incorrectly marked retryable', async () => {
const operationId = '123e4567-e89b-42d3-a456-426614174000';
api.bindAiHardwareDevice
.mockRejectedValueOnce(new AiHardwareApiError({
status: 422,
code: 'ai_hardware_activation_code_invalid',
message: 'private',
retryable: true,
operationId,
}))
.mockResolvedValueOnce(device);
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '绑定设备' }));
const input = screen.getByLabelText('6 位激活码');
fireEvent.change(input, { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
const retryInput = await screen.findByLabelText('6 位激活码');
expect(retryInput).toHaveValue('');
fireEvent.change(retryInput, { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2));
expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['031425', agentOne.id]);
});
it('keeps an unset chat history unchanged when saving without edits', async () => {
api.getAiHardwareAgentConfiguration.mockResolvedValueOnce({
data: { ...configuration, chat_history_conf: null },