Files
makelore/tests/unit/robot-hotspot-module.test.ts

271 lines
12 KiB
TypeScript

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> = {}): 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('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<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');
});
});