271 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|