feat(robot): connect provisioning hotspots in app

This commit is contained in:
2026-08-16 20:18:28 +08:00
parent abecd5f344
commit c1326a2980
17 changed files with 1950 additions and 11 deletions

View File

@@ -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' },

View File

@@ -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: '机器人已进入配网模式' }));

View File

@@ -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,

View 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');
});
});