feat(robot): connect provisioning hotspots in app
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
createAiHardwareOperationId,
|
||||
recoverAiHardwareCredential,
|
||||
openAiHardwareProvisioningPortal,
|
||||
scanAiHardwareProvisioningHotspots,
|
||||
connectAiHardwareProvisioningHotspot,
|
||||
} from '@/lib/ai-hardware';
|
||||
|
||||
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
||||
@@ -80,6 +82,99 @@ describe('AI hardware renderer API', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('strictly projects Robot hotspot scan and connection DTOs', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
platform: 'macos',
|
||||
hotspots: [{
|
||||
candidate_id: 'candidate_123',
|
||||
ssid: 'Xiaozhi-Desk',
|
||||
signal_percent: 72,
|
||||
connected: false,
|
||||
}],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { connected: true, candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk' },
|
||||
});
|
||||
|
||||
await expect(scanAiHardwareProvisioningHotspots()).resolves.toEqual({
|
||||
platform: 'macos',
|
||||
hotspots: [{
|
||||
candidateId: 'candidate_123',
|
||||
ssid: 'Xiaozhi-Desk',
|
||||
signalPercent: 72,
|
||||
connected: false,
|
||||
}],
|
||||
});
|
||||
await expect(connectAiHardwareProvisioningHotspot('candidate_123')).resolves.toEqual({
|
||||
connected: true,
|
||||
candidateId: 'candidate_123',
|
||||
ssid: 'Xiaozhi-Desk',
|
||||
});
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/works/ai-hardware/provisioning-hotspots/scan',
|
||||
{ method: 'POST', body: '{}' },
|
||||
);
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/works/ai-hardware/provisioning-hotspots/connect',
|
||||
{ method: 'POST', body: JSON.stringify({ candidate_id: 'candidate_123' }) },
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ platform: 'linux', hotspots: [] },
|
||||
{ platform: 'windows', hotspots: [], native_interface: 'secret' },
|
||||
{
|
||||
platform: 'windows',
|
||||
hotspots: [{
|
||||
candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk', signal_percent: 101,
|
||||
connected: false,
|
||||
}],
|
||||
},
|
||||
{
|
||||
platform: 'windows',
|
||||
hotspots: [{
|
||||
candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk', signal_percent: 72,
|
||||
connected: false, bssid: 'secret',
|
||||
}],
|
||||
},
|
||||
])('rejects malformed or expanded hotspot scan DTO %#', async (data) => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ success: true, data });
|
||||
await expect(scanAiHardwareProvisioningHotspots()).rejects.toMatchObject({
|
||||
code: 'AI_HARDWARE_INVALID_RESPONSE',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ connected: false, candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk' },
|
||||
{ connected: true, candidate_id: '../ssid', ssid: 'Xiaozhi-Desk' },
|
||||
{
|
||||
connected: true, candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk',
|
||||
native_profile: 'secret',
|
||||
},
|
||||
])('rejects malformed or expanded hotspot connection DTO %#', async (data) => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ success: true, data });
|
||||
await expect(connectAiHardwareProvisioningHotspot('candidate_123')).rejects.toMatchObject({
|
||||
code: 'AI_HARDWARE_INVALID_RESPONSE',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid hotspot candidate IDs before calling Host API', async () => {
|
||||
await expect(connectAiHardwareProvisioningHotspot('../ssid')).rejects.toThrow(
|
||||
'candidateId is invalid',
|
||||
);
|
||||
await expect(connectAiHardwareProvisioningHotspot('a'.repeat(129))).rejects.toThrow(
|
||||
'candidateId is invalid',
|
||||
);
|
||||
expect(hostApiFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ guided_hotspot_binding: 'true' },
|
||||
{ guided_hotspot_binding: false, portal_url: 'http://unsafe.example' },
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
const api = vi.hoisted(() => ({
|
||||
getAiHardwareOverview: vi.fn(),
|
||||
getAiHardwareProvisioningCapabilities: vi.fn(),
|
||||
scanAiHardwareProvisioningHotspots: vi.fn(),
|
||||
connectAiHardwareProvisioningHotspot: vi.fn(),
|
||||
openAiHardwareProvisioningPortal: vi.fn(),
|
||||
recoverAiHardwareCredential: vi.fn(),
|
||||
createAiHardwareAgent: vi.fn(),
|
||||
@@ -87,10 +89,19 @@ async function openConfigurationEditor(): Promise<void> {
|
||||
await screen.findByRole('heading', { name: '编辑智能体配置' });
|
||||
}
|
||||
|
||||
async function openGuidedHotspotStep(): Promise<void> {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
|
||||
await screen.findByRole('heading', { name: '连接设备热点' });
|
||||
}
|
||||
|
||||
describe('AI hardware page', () => {
|
||||
beforeEach(() => {
|
||||
api.getAiHardwareOverview.mockResolvedValue(activeOverview);
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValue({ guidedHotspotBinding: false });
|
||||
api.scanAiHardwareProvisioningHotspots.mockResolvedValue({ platform: 'windows', hotspots: [] });
|
||||
api.connectAiHardwareProvisioningHotspot.mockResolvedValue({ connected: true, candidateId: 'candidate-a', ssid: 'Xiaozhi-A' });
|
||||
api.openAiHardwareProvisioningPortal.mockResolvedValue({ opened: true });
|
||||
api.recoverAiHardwareCredential.mockResolvedValue({ status: 'active', agents: [], devices: [] });
|
||||
api.getAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 4 });
|
||||
@@ -176,6 +187,142 @@ describe('AI hardware page', () => {
|
||||
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '开始引导配网' })).not.toBeInTheDocument();
|
||||
expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled();
|
||||
expect(api.scanAiHardwareProvisioningHotspots).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('automatically scans, lists candidates, and advances only after an explicit verified connection', async () => {
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
|
||||
api.scanAiHardwareProvisioningHotspots.mockResolvedValueOnce({
|
||||
platform: 'windows',
|
||||
hotspots: [
|
||||
{ candidateId: 'candidate-a', ssid: 'Xiaozhi-A', signalPercent: 82, connected: false },
|
||||
{ candidateId: 'candidate-b', ssid: 'Xiaozhi-B', signalPercent: 47, connected: true },
|
||||
],
|
||||
});
|
||||
api.connectAiHardwareProvisioningHotspot.mockResolvedValueOnce({
|
||||
connected: true, candidateId: 'candidate-b', ssid: 'Xiaozhi-B',
|
||||
});
|
||||
render(<AiHardware />);
|
||||
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
|
||||
|
||||
await openGuidedHotspotStep();
|
||||
|
||||
await waitFor(() => expect(api.scanAiHardwareProvisioningHotspots).toHaveBeenCalledOnce());
|
||||
expect(await screen.findByRole('button', { name: '选择 Xiaozhi-A' })).toHaveTextContent('信号 82%');
|
||||
expect(screen.getByRole('button', { name: '选择 Xiaozhi-B' })).toHaveTextContent('已连接');
|
||||
expect(screen.getByRole('button', { name: '连接所选热点' })).toBeDisabled();
|
||||
expect(api.connectAiHardwareProvisioningHotspot).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择 Xiaozhi-B' }));
|
||||
const connectButton = screen.getByRole('button', { name: '连接所选热点' });
|
||||
fireEvent.click(connectButton);
|
||||
fireEvent.click(connectButton);
|
||||
|
||||
await waitFor(() => expect(api.connectAiHardwareProvisioningHotspot).toHaveBeenCalledOnce());
|
||||
expect(api.connectAiHardwareProvisioningHotspot).toHaveBeenCalledWith('candidate-b');
|
||||
expect(await screen.findByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument();
|
||||
const cancelButton = screen.getByRole('button', { name: '取消' });
|
||||
expect(cancelButton).toBeEnabled();
|
||||
fireEvent.click(cancelButton);
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: '配置机器人 Wi-Fi' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows an empty scan result and supports rescanning without selecting a hotspot', async () => {
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
|
||||
api.scanAiHardwareProvisioningHotspots
|
||||
.mockResolvedValueOnce({ platform: 'windows', hotspots: [] })
|
||||
.mockResolvedValueOnce({
|
||||
platform: 'windows',
|
||||
hotspots: [{ candidateId: 'candidate-new', ssid: 'Xiaozhi-New', signalPercent: 61, connected: false }],
|
||||
});
|
||||
render(<AiHardware />);
|
||||
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
|
||||
|
||||
await openGuidedHotspotStep();
|
||||
|
||||
expect(await screen.findByText('没有发现设备热点')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '连接所选热点' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }));
|
||||
expect(await screen.findByRole('button', { name: '选择 Xiaozhi-New' })).toHaveTextContent('信号 61%');
|
||||
expect(api.scanAiHardwareProvisioningHotspots).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['AI_HARDWARE_HOTSPOT_PERMISSION_DENIED', '没有检查附近热点所需的系统权限'],
|
||||
['AI_HARDWARE_HOTSPOT_UNSUPPORTED', '当前系统不支持在 Makelore 内连接热点'],
|
||||
['AI_HARDWARE_HOTSPOT_BUSY', '系统正在处理其他 Wi-Fi 操作'],
|
||||
['AI_HARDWARE_HOTSPOT_SCAN_FAILED', '暂时无法检查设备热点'],
|
||||
])('shows a safe %s scan error and preserves the manual fallback', async (code, expected) => {
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
|
||||
api.scanAiHardwareProvisioningHotspots.mockRejectedValueOnce(new AiHardwareApiError({
|
||||
status: 200, code, message: 'native adapter secret path',
|
||||
}));
|
||||
render(<AiHardware />);
|
||||
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
|
||||
|
||||
await openGuidedHotspotStep();
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(expected);
|
||||
expect(screen.queryByText(/native adapter secret path/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
|
||||
expect(screen.getByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['AI_HARDWARE_HOTSPOT_BUSY', '系统正在处理其他 Wi-Fi 操作'],
|
||||
['AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED', '这个热点候选已失效'],
|
||||
['AI_HARDWARE_HOTSPOT_CONNECT_FAILED', '没有成功连接并验证'],
|
||||
])('shows a safe %s connection error and allows retrying the selected candidate', async (code, expected) => {
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
|
||||
api.scanAiHardwareProvisioningHotspots.mockResolvedValueOnce({
|
||||
platform: 'macos',
|
||||
hotspots: [{ candidateId: 'candidate-a', ssid: 'Xiaozhi-A', signalPercent: 70, connected: false }],
|
||||
});
|
||||
api.connectAiHardwareProvisioningHotspot.mockRejectedValueOnce(new AiHardwareApiError({
|
||||
status: 200,
|
||||
code,
|
||||
message: 'native command secret',
|
||||
}));
|
||||
render(<AiHardware />);
|
||||
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
|
||||
|
||||
await openGuidedHotspotStep();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '选择 Xiaozhi-A' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '连接所选热点' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(expected);
|
||||
expect(screen.queryByText(/native command secret/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '连接所选热点' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('ignores a late scan after dismiss and reopen', async () => {
|
||||
const staleScan = deferred<{
|
||||
platform: 'windows';
|
||||
hotspots: Array<{ candidateId: string; ssid: string; signalPercent: number; connected: boolean }>;
|
||||
}>();
|
||||
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
|
||||
api.scanAiHardwareProvisioningHotspots
|
||||
.mockReturnValueOnce(staleScan.promise)
|
||||
.mockResolvedValueOnce({
|
||||
platform: 'windows',
|
||||
hotspots: [{ candidateId: 'fresh', ssid: 'Xiaozhi-Fresh', signalPercent: 75, connected: false }],
|
||||
});
|
||||
render(<AiHardware />);
|
||||
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
|
||||
|
||||
await openGuidedHotspotStep();
|
||||
expect(screen.getByText(/正在检查附近的设备热点/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
|
||||
await openGuidedHotspotStep();
|
||||
expect(await screen.findByRole('button', { name: '选择 Xiaozhi-Fresh' })).toBeInTheDocument();
|
||||
staleScan.resolve({
|
||||
platform: 'windows',
|
||||
hotspots: [{ candidateId: 'stale', ssid: 'Xiaozhi-Stale', signalPercent: 99, connected: false }],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: '选择 Xiaozhi-Stale' })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: '选择 Xiaozhi-Fresh' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows and binds devices for the currently selected agent', async () => {
|
||||
@@ -221,7 +368,7 @@ describe('AI hardware page', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' }));
|
||||
expect(screen.getByText(/设备热点没有加密保护/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
|
||||
expect(screen.getByText(/Xiaozhi-\*/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/设备热点未加密/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
|
||||
expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
|
||||
|
||||
@@ -44,6 +44,23 @@ function setup(
|
||||
options: {
|
||||
guidedHotspotBinding?: boolean;
|
||||
openExternal?: (url: string) => Promise<void>;
|
||||
robotHotspot?: {
|
||||
scan: () => Promise<{
|
||||
platform: 'windows' | 'macos';
|
||||
hotspots: Array<{
|
||||
candidateId: string;
|
||||
ssid: string;
|
||||
signalPercent: number;
|
||||
connected: boolean;
|
||||
}>;
|
||||
}>;
|
||||
connect: (candidateId: string) => Promise<{
|
||||
connected: true;
|
||||
candidateId: string;
|
||||
ssid: string;
|
||||
}>;
|
||||
clear: () => void;
|
||||
};
|
||||
} = {},
|
||||
) {
|
||||
const getAccessToken = vi.fn().mockResolvedValue('secret-token');
|
||||
@@ -263,6 +280,183 @@ describe('AI hardware Host API route', () => {
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scans and connects Robot hotspots locally before credentials or upstream access', async () => {
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockResolvedValue({
|
||||
platform: 'windows' as const,
|
||||
hotspots: [{
|
||||
candidateId: 'candidate_123',
|
||||
ssid: 'Xiaozhi-Desk',
|
||||
signalPercent: 78,
|
||||
connected: false,
|
||||
nativeInterface: 'must-not-pass',
|
||||
}],
|
||||
}),
|
||||
connect: vi.fn().mockResolvedValue({
|
||||
connected: true as const,
|
||||
candidateId: 'candidate_123',
|
||||
ssid: 'Xiaozhi-Desk',
|
||||
profile: 'must-not-pass',
|
||||
}),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const { handler, fetchImpl, getAccessToken } = setup(undefined, { robotHotspot });
|
||||
|
||||
const scanned = await invoke(
|
||||
handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-hotspots/scan',
|
||||
{},
|
||||
);
|
||||
const connected = await invoke(
|
||||
handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-hotspots/connect',
|
||||
{ candidate_id: 'candidate_123' },
|
||||
);
|
||||
|
||||
expect(scanned.payload).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
platform: 'windows',
|
||||
hotspots: [{
|
||||
candidate_id: 'candidate_123',
|
||||
ssid: 'Xiaozhi-Desk',
|
||||
signal_percent: 78,
|
||||
connected: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
expect(connected.payload).toEqual({
|
||||
success: true,
|
||||
data: { connected: true, candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk' },
|
||||
});
|
||||
expect(robotHotspot.connect).toHaveBeenCalledWith('candidate_123');
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects hotspot queries, missing or expanded bodies, and invalid candidate IDs locally', async () => {
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockResolvedValue({ platform: 'windows' as const, hotspots: [] }),
|
||||
connect: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const { handler, fetchImpl, getAccessToken } = setup(undefined, { robotHotspot });
|
||||
const requests = [
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan?refresh=1', {}),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan'),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan', { extra: true }),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect?retry=1', { candidate_id: 'candidate_123' }),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', {}),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', { candidate_id: '../ssid' }),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', {
|
||||
candidate_id: 'a'.repeat(129),
|
||||
}),
|
||||
invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', {
|
||||
candidate_id: 'candidate_123', ssid: 'Xiaozhi-Evil',
|
||||
}),
|
||||
];
|
||||
|
||||
for (const result of await Promise.all(requests)) {
|
||||
expect(result.payload).toMatchObject({
|
||||
success: false,
|
||||
status: 400,
|
||||
code: 'AI_HARDWARE_INVALID_REQUEST',
|
||||
});
|
||||
}
|
||||
expect(robotHotspot.scan).not.toHaveBeenCalled();
|
||||
expect(robotHotspot.connect).not.toHaveBeenCalled();
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables both local hotspot operations with the guided provisioning capability', async () => {
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockResolvedValue({ platform: 'windows' as const, hotspots: [] }),
|
||||
connect: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const disabled = setup(undefined, { guidedHotspotBinding: false, robotHotspot });
|
||||
|
||||
for (const result of await Promise.all([
|
||||
invoke(disabled.handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan', {}),
|
||||
invoke(disabled.handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', {
|
||||
candidate_id: 'candidate_123',
|
||||
}),
|
||||
])) {
|
||||
expect(result.payload).toMatchObject({
|
||||
success: false,
|
||||
status: 403,
|
||||
code: 'AI_HARDWARE_PROVISIONING_DISABLED',
|
||||
});
|
||||
}
|
||||
expect(robotHotspot.scan).not.toHaveBeenCalled();
|
||||
expect(robotHotspot.connect).not.toHaveBeenCalled();
|
||||
expect(disabled.getAccessToken).not.toHaveBeenCalled();
|
||||
expect(disabled.fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unsupported', 501, 'AI_HARDWARE_HOTSPOT_UNSUPPORTED'],
|
||||
['permission_denied', 403, 'AI_HARDWARE_HOTSPOT_PERMISSION_DENIED'],
|
||||
['busy', 409, 'AI_HARDWARE_HOTSPOT_BUSY'],
|
||||
['candidate_expired', 409, 'AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED'],
|
||||
['scan_failed', 502, 'AI_HARDWARE_HOTSPOT_SCAN_FAILED'],
|
||||
])('projects native hotspot scan error %s safely', async (nativeCode, status, code) => {
|
||||
const nativeError = Object.assign(new Error('native interface and location secret'), {
|
||||
code: nativeCode,
|
||||
});
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockRejectedValue(nativeError),
|
||||
connect: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const local = setup(undefined, { robotHotspot });
|
||||
const result = await invoke(
|
||||
local.handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-hotspots/scan',
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.payload).toMatchObject({ success: false, status, code, retryable: false });
|
||||
expect(JSON.stringify(result.payload)).not.toContain('native interface');
|
||||
expect(local.getAccessToken).not.toHaveBeenCalled();
|
||||
expect(local.fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('projects native connect failures safely', async () => {
|
||||
const robotHotspot = {
|
||||
scan: vi.fn().mockResolvedValue({ platform: 'macos' as const, hotspots: [] }),
|
||||
connect: vi.fn().mockRejectedValue(Object.assign(new Error('native profile secret'), {
|
||||
code: 'connect_failed',
|
||||
})),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const local = setup(undefined, { robotHotspot });
|
||||
const result = await invoke(
|
||||
local.handler,
|
||||
'POST',
|
||||
'/api/works/ai-hardware/provisioning-hotspots/connect',
|
||||
{ candidate_id: 'candidate_123' },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.payload).toEqual({
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'AI_HARDWARE_HOTSPOT_CONNECT_FAILED',
|
||||
error: 'The Robot hotspot could not be connected',
|
||||
retryable: false,
|
||||
});
|
||||
expect(JSON.stringify(result.payload)).not.toContain('native profile secret');
|
||||
expect(local.getAccessToken).not.toHaveBeenCalled();
|
||||
expect(local.fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proxies only the fixed catalog query and projects its safe DTO', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({
|
||||
schema_version: 1,
|
||||
|
||||
214
tests/unit/robot-hotspot-module.test.ts
Normal file
214
tests/unit/robot-hotspot-module.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RobotHotspotAdapter, RobotHotspotAdapterCandidate } from '@electron/robot-hotspot/adapter';
|
||||
import { RobotHotspotError, createRobotHotspotModule } from '@electron/robot-hotspot';
|
||||
import { MacosWorkerController, requestMacosWorker } from '@electron/robot-hotspot/macos';
|
||||
import type { MacosWorkerLike } from '@electron/robot-hotspot/macos';
|
||||
|
||||
function candidate(ssid: string, signalPercent: number, overrides: Partial<RobotHotspotAdapterCandidate> = {}): RobotHotspotAdapterCandidate {
|
||||
return { ssid, signalPercent, connected: false, open: true, nativeKey: ssid, ...overrides };
|
||||
}
|
||||
|
||||
function fakeAdapter(hotspots: RobotHotspotAdapterCandidate[]): RobotHotspotAdapter {
|
||||
return {
|
||||
scan: vi.fn(async () => hotspots),
|
||||
connect: vi.fn(async () => {}),
|
||||
currentSsid: vi.fn(async () => hotspots[0]?.ssid ?? null),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
|
||||
await expect(promise).rejects.toMatchObject({ name: 'RobotHotspotError', code });
|
||||
}
|
||||
|
||||
describe('Robot hotspot module', () => {
|
||||
it('terminates a hanging macOS native worker when the request is aborted', async () => {
|
||||
const worker = {
|
||||
postMessage: vi.fn(),
|
||||
once: vi.fn(function (this: MacosWorkerLike) { return this; }),
|
||||
off: vi.fn(function (this: MacosWorkerLike) { return this; }),
|
||||
terminate: vi.fn(async () => 1),
|
||||
} as unknown as MacosWorkerLike;
|
||||
const controller = new AbortController();
|
||||
const request = requestMacosWorker(worker, { id: 1, operation: 'scan' }, controller.signal);
|
||||
const timeout = new RobotHotspotError('scan_failed');
|
||||
|
||||
controller.abort(timeout);
|
||||
|
||||
await expect(request).rejects.toBe(timeout);
|
||||
expect(worker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not create or post to a replacement macOS worker until prior termination settles', async () => {
|
||||
let finishTermination!: (code: number) => void;
|
||||
const firstTermination = new Promise<number>((resolve) => { finishTermination = resolve; });
|
||||
const createFakeWorker = (termination: Promise<number>): MacosWorkerLike => ({
|
||||
postMessage: vi.fn(),
|
||||
once: vi.fn(function (this: MacosWorkerLike) { return this; }),
|
||||
off: vi.fn(function (this: MacosWorkerLike) { return this; }),
|
||||
terminate: vi.fn(() => termination),
|
||||
});
|
||||
const firstWorker = createFakeWorker(firstTermination);
|
||||
const secondWorker = createFakeWorker(Promise.resolve(1));
|
||||
const factory = vi.fn()
|
||||
.mockReturnValueOnce(firstWorker)
|
||||
.mockReturnValueOnce(secondWorker);
|
||||
const controller = new MacosWorkerController(factory);
|
||||
const firstAbort = new AbortController();
|
||||
const firstRequest = controller.request({ operation: 'scan' }, firstAbort.signal);
|
||||
await vi.waitFor(() => expect(firstWorker.postMessage).toHaveBeenCalledOnce());
|
||||
firstAbort.abort(new RobotHotspotError('scan_failed'));
|
||||
await expect(firstRequest).rejects.toMatchObject({ code: 'scan_failed' });
|
||||
|
||||
const secondAbort = new AbortController();
|
||||
const secondRequest = controller.request({ operation: 'scan' }, secondAbort.signal);
|
||||
await Promise.resolve();
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(secondWorker.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
finishTermination(1);
|
||||
await vi.waitFor(() => expect(secondWorker.postMessage).toHaveBeenCalledOnce());
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
secondAbort.abort(new RobotHotspotError('scan_failed'));
|
||||
await expect(secondRequest).rejects.toMatchObject({ code: 'scan_failed' });
|
||||
expect(firstWorker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not continue to permission or native work when aborted behind the termination barrier', async () => {
|
||||
let finishTermination!: (code: number) => void;
|
||||
const firstTermination = new Promise<number>((resolve) => { finishTermination = resolve; });
|
||||
const firstWorker = {
|
||||
postMessage: vi.fn(),
|
||||
once: vi.fn(function (this: MacosWorkerLike) { return this; }),
|
||||
off: vi.fn(function (this: MacosWorkerLike) { return this; }),
|
||||
terminate: vi.fn(() => firstTermination),
|
||||
} as unknown as MacosWorkerLike;
|
||||
const replacementWorker = {
|
||||
postMessage: vi.fn(),
|
||||
once: vi.fn(function (this: MacosWorkerLike) { return this; }),
|
||||
off: vi.fn(function (this: MacosWorkerLike) { return this; }),
|
||||
terminate: vi.fn(async () => 1),
|
||||
} as unknown as MacosWorkerLike;
|
||||
const factory = vi.fn()
|
||||
.mockReturnValueOnce(firstWorker)
|
||||
.mockReturnValueOnce(replacementWorker);
|
||||
const controller = new MacosWorkerController(factory);
|
||||
const firstAbort = new AbortController();
|
||||
const firstRequest = controller.request({ operation: 'scan' }, firstAbort.signal);
|
||||
await vi.waitFor(() => expect(firstWorker.postMessage).toHaveBeenCalledOnce());
|
||||
firstAbort.abort(new RobotHotspotError('scan_failed'));
|
||||
await expect(firstRequest).rejects.toMatchObject({ code: 'scan_failed' });
|
||||
|
||||
const permissionOrNativeContinuation = vi.fn();
|
||||
const blockedAbort = new AbortController();
|
||||
const blockedFlow = (async () => {
|
||||
await controller.ready(blockedAbort.signal);
|
||||
permissionOrNativeContinuation();
|
||||
await controller.request({ operation: 'scan' }, blockedAbort.signal);
|
||||
})();
|
||||
blockedAbort.abort(new RobotHotspotError('scan_failed'));
|
||||
finishTermination(1);
|
||||
|
||||
await expect(blockedFlow).rejects.toMatchObject({ code: 'scan_failed' });
|
||||
expect(permissionOrNativeContinuation).not.toHaveBeenCalled();
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(replacementWorker.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'win32')('loads the Windows WLAN adapter definitions', async () => {
|
||||
const { createWindowsRobotHotspotAdapter } = await import('@electron/robot-hotspot/windows');
|
||||
await expect(createWindowsRobotHotspotAdapter()).resolves.toMatchObject({
|
||||
scan: expect.any(Function), connect: expect.any(Function), currentSsid: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('projects only open printable Xiaozhi SSIDs and merges duplicates using the strongest signal', async () => {
|
||||
const adapter = fakeAdapter([
|
||||
candidate('Other-1', 100),
|
||||
candidate('Xiaozhi-secured', 90, { open: false }),
|
||||
candidate('Xiaozhi-bad\nname', 90),
|
||||
candidate('Xiaozhi-bidi\u202eoverride', 90),
|
||||
candidate('Xiaozhi-zero\u200bwidth', 90),
|
||||
candidate('Xiaozhi-line\u2028separator', 90),
|
||||
candidate('Xiaozhi-lone\ud800surrogate', 90),
|
||||
candidate(`Xiaozhi-${'界'.repeat(9)}`, 80),
|
||||
candidate('Xiaozhi-device', 20),
|
||||
candidate('Xiaozhi-device', 81, { connected: true }),
|
||||
]);
|
||||
const module = createRobotHotspotModule('windows', adapter, { createId: () => 'opaque-id' });
|
||||
|
||||
await expect(module.scan()).resolves.toEqual({
|
||||
platform: 'windows',
|
||||
hotspots: [{ candidateId: 'opaque-id', ssid: 'Xiaozhi-device', signalPercent: 81, connected: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds the projected candidate list', async () => {
|
||||
const adapter = fakeAdapter(Array.from(
|
||||
{ length: 70 },
|
||||
(_, index) => candidate(`Xiaozhi-${String(index).padStart(2, '0')}`, 70 - index),
|
||||
));
|
||||
let id = 0;
|
||||
const module = createRobotHotspotModule('windows', adapter, { createId: () => `id-${id += 1}` });
|
||||
|
||||
const result = await module.scan();
|
||||
|
||||
expect(result.hotspots).toHaveLength(64);
|
||||
expect(result.hotspots.at(0)?.ssid).toBe('Xiaozhi-00');
|
||||
expect(result.hotspots.at(-1)?.ssid).toBe('Xiaozhi-63');
|
||||
});
|
||||
|
||||
it('expires candidates after the short snapshot TTL and when clear is called', async () => {
|
||||
let now = 10;
|
||||
const adapter = fakeAdapter([candidate('Xiaozhi-one', 50)]);
|
||||
const module = createRobotHotspotModule('macos', adapter, { now: () => now, candidateTtlMs: 100, createId: () => 'id-1' });
|
||||
await module.scan();
|
||||
now = 110;
|
||||
await expectCode(module.connect('id-1'), 'candidate_expired');
|
||||
|
||||
now = 10;
|
||||
await module.scan();
|
||||
module.clear();
|
||||
await expectCode(module.connect('id-1'), 'candidate_expired');
|
||||
});
|
||||
|
||||
it('rejects overlapping native work as busy', async () => {
|
||||
let finishConnect!: () => void;
|
||||
const adapter = fakeAdapter([candidate('Xiaozhi-one', 50)]);
|
||||
adapter.connect = vi.fn(() => new Promise<void>((resolve) => { finishConnect = resolve; }));
|
||||
adapter.currentSsid = vi.fn(async () => 'Xiaozhi-one');
|
||||
const module = createRobotHotspotModule('windows', adapter, { createId: () => 'id-1' });
|
||||
await module.scan();
|
||||
const connecting = module.connect('id-1');
|
||||
await Promise.resolve();
|
||||
|
||||
await expectCode(module.scan(), 'busy');
|
||||
finishConnect();
|
||||
await expect(connecting).resolves.toMatchObject({ connected: true, ssid: 'Xiaozhi-one' });
|
||||
});
|
||||
|
||||
it('does not report success until the exact current SSID is verified', async () => {
|
||||
const adapter = fakeAdapter([candidate('Xiaozhi-target', 50)]);
|
||||
adapter.currentSsid = vi.fn()
|
||||
.mockResolvedValueOnce('Xiaozhi-other')
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce('Xiaozhi-target');
|
||||
const module = createRobotHotspotModule('windows', adapter, {
|
||||
createId: () => 'id-1', verificationIntervalMs: 1, connectTimeoutMs: 100,
|
||||
});
|
||||
await module.scan();
|
||||
|
||||
await expect(module.connect('id-1')).resolves.toEqual({ connected: true, candidateId: 'id-1', ssid: 'Xiaozhi-target' });
|
||||
expect(adapter.currentSsid).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('keeps stable errors free of native diagnostics', async () => {
|
||||
const adapter = fakeAdapter([]);
|
||||
adapter.scan = vi.fn(async () => { throw new Error('native secret diagnostic'); });
|
||||
const module = createRobotHotspotModule('windows', adapter);
|
||||
|
||||
const error = await module.scan().catch((caught: unknown) => caught);
|
||||
expect(error).toEqual(new RobotHotspotError('scan_failed'));
|
||||
expect(String(error)).not.toContain('native secret diagnostic');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user