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, scanMacosCandidatesWithRetry } from '@electron/robot-hotspot/macos'; import type { MacosWorkerLike } from '@electron/robot-hotspot/macos'; function candidate(ssid: string, signalPercent: number, overrides: Partial = {}): 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, code: string): Promise { await expect(promise).rejects.toMatchObject({ name: 'RobotHotspotError', code }); } describe('Robot hotspot module', () => { it('retries once when the first macOS scan hides every SSID and returns the recovered open hotspot', () => { const readNetworks = vi.fn() .mockReturnValueOnce([ { ssid: null, rssi: -80, open: true }, { ssid: '', rssi: -60, open: true }, ]) .mockReturnValueOnce([ { ssid: 'Xiaozhi-7A2B', rssi: -58, open: true }, { ssid: 'Private-Network', rssi: -42, open: false }, ]); const waitBeforeRetry = vi.fn(); expect(scanMacosCandidatesWithRetry(readNetworks, 'Xiaozhi-7A2B', waitBeforeRetry)).toEqual([ { ssid: 'Xiaozhi-7A2B', signalPercent: 84, connected: true, open: true }, ]); expect(readNetworks).toHaveBeenCalledTimes(2); expect(waitBeforeRetry).toHaveBeenCalledOnce(); }); it('reports permission denied when both macOS scans find networks but expose no SSIDs', () => { const readNetworks = vi.fn() .mockReturnValueOnce([{ ssid: null, rssi: -70, open: true }]) .mockReturnValueOnce([{ ssid: '', rssi: -65, open: true }]); expect(() => scanMacosCandidatesWithRetry(readNetworks, null, vi.fn())).toThrowError( expect.objectContaining({ reason: 'permission_denied' }), ); expect(readNetworks).toHaveBeenCalledTimes(2); }); it('bounds an empty macOS scan retry and keeps an empty result empty', () => { const readNetworks = vi.fn().mockReturnValue([]); const waitBeforeRetry = vi.fn(); expect(scanMacosCandidatesWithRetry(readNetworks, null, waitBeforeRetry)).toEqual([]); expect(readNetworks).toHaveBeenCalledTimes(2); expect(waitBeforeRetry).toHaveBeenCalledOnce(); }); it('does not retry a readable macOS scan and filters secured networks while clamping RSSI', () => { const readNetworks = vi.fn().mockReturnValue([ { ssid: 'Xiaozhi-weak', rssi: -120, open: true }, { ssid: 'Xiaozhi-strong', rssi: -20, open: true }, { ssid: 'Xiaozhi-secured', rssi: -50, open: false }, { ssid: null, rssi: -40, open: true }, ]); const waitBeforeRetry = vi.fn(); expect(scanMacosCandidatesWithRetry(readNetworks, 'Xiaozhi-strong', waitBeforeRetry)).toEqual([ { ssid: 'Xiaozhi-weak', signalPercent: 0, connected: false, open: true }, { ssid: 'Xiaozhi-strong', signalPercent: 100, connected: true, open: true }, ]); expect(readNetworks).toHaveBeenCalledOnce(); expect(waitBeforeRetry).not.toHaveBeenCalled(); }); 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((resolve) => { finishTermination = resolve; }); const createFakeWorker = (termination: Promise): 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((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((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'); }); });