Files

126 lines
6.1 KiB
JavaScript
Raw Permalink Normal View History

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const source = readFileSync(new URL('../src/ble/client.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText;
const { BLEClient, BLE_SERVICE, BLEDeviceSelectionCancelledError } = await import('data:text/javascript;base64,' + Buffer.from(compiled).toString('base64'));
globalThis.window = { isSecureContext: true };
for (const name of ['NotFoundError', 'AbortError']) test(`localizes ${name} from the device chooser and allows retry`, async () => {
Object.defineProperty(globalThis.navigator, 'bluetooth', { configurable: true, value: {
async requestDevice() { throw new DOMException('User cancelled the requestDevice() chooser.', name); },
} });
const client = new BLEClient();
await assert.rejects(client.connect(), error => error instanceof BLEDeviceSelectionCancelledError && error.message === '已取消选择设备,可重新搜索。');
const { gatt } = fake();
assert.equal((await client.connect()).protocol_version, 2);
client.disconnect();
assert.equal(gatt.connected, false);
});
test('does not mistake a missing GATT service for cancelling the chooser', async () => {
const { gatt } = fake();
const missing = new DOMException('Missing GATT service', 'NotFoundError');
gatt.getPrimaryService = async () => { throw missing; };
await assert.rejects(new BLEClient().connect(), error => error === missing && !(error instanceof BLEDeviceSelectionCancelledError));
assert.equal(gatt.connected, false);
});
function fake(mode = 'normal') {
const tx = new EventTarget();
tx.startNotifications = async () => tx;
const writes = [];
const commands = [];
let input = Buffer.alloc(0);
const gatt = {
connected: false,
async connect() { this.connected = true; return this; },
disconnect() { this.connected = false; },
async getPrimaryService(id) {
assert.equal(id, BLE_SERVICE);
return { getCharacteristic: async id => id.includes('2001-') ? rx : tx };
},
};
const rx = { async writeValueWithResponse(bytes) {
assert(bytes.length <= 20);
writes.push(bytes);
input = Buffer.concat([input, bytes]);
if (input.length < 2 || input.length < 2 + input.readUInt16LE(0)) return;
assert.equal(input.length, 2 + input.readUInt16LE(0));
const command = JSON.parse(input.subarray(2).toString('utf8'));
commands.push(command);
let response = command.cmd === 'get_info'
? { device_id: 'KP01-B43A451634A8', model: 'ECHOEAR-S3', protocol_version: mode === 'bad-protocol' ? 1 : 2 }
: { status: 'received' };
const body = Buffer.from(JSON.stringify(response));
const frame = Buffer.concat([Buffer.from([body.length & 255, body.length >> 8]), body]);
input = Buffer.alloc(0);
if (mode === 'oversize') {
tx.value = new DataView(new Uint8Array(21).buffer);
tx.dispatchEvent(new Event('characteristicvaluechanged'));
return;
}
for (let i = 0; i < frame.length; i += 7) {
const chunk = Uint8Array.from(frame.subarray(i, i + 7));
tx.value = new DataView(chunk.buffer);
tx.dispatchEvent(new Event('characteristicvaluechanged'));
}
} };
const device = Object.assign(new EventTarget(), { gatt });
Object.defineProperty(globalThis.navigator, 'bluetooth', { configurable: true, value: {
async requestDevice({ filters }) {
assert.deepEqual(filters, [{ services: [BLE_SERVICE] }]);
return device;
},
} });
return { writes, commands, gatt };
}
test('discovers by service and uses the same plaintext v2 frames as the mini program', async () => {
const { writes, commands, gatt } = fake();
const client = new BLEClient();
const info = await client.connect();
assert.deepEqual(info, { device_id: 'KP01-B43A451634A8', model: 'ECHOEAR-S3', protocol_version: 2 });
assert.deepEqual(commands[0], { cmd: 'get_info' });
assert.equal(writes[0][0] | writes[0][1] << 8, Buffer.byteLength(JSON.stringify({ cmd: 'get_info' })));
assert.deepEqual(await client.configure({ ssid: 'HomeWiFi', password: 'test-only-password', token: 'x'.repeat(43) }), { status: 'received' });
assert.deepEqual(commands[1], { cmd: 'configure', wifi_ssid: 'HomeWiFi', wifi_password: 'test-only-password', bind_token: 'x'.repeat(43) });
client.disconnect();
assert.equal(gatt.connected, false);
});
for (const mode of ['oversize', 'bad-protocol']) test(`rejects ${mode} response and disconnects`, async () => {
const { gatt } = fake(mode);
const client = new BLEClient();
await assert.rejects(client.connect());
assert.equal(gatt.connected, false);
});
test('rejects invalid Wi-Fi data before sending it to the device', async () => {
const { commands } = fake();
const client = new BLEClient();
await client.connect();
await assert.rejects(client.configure({ ssid: '', password: 'short', token: 'bad' }), /格式不正确/);
assert.equal(commands.length, 1);
});
test('Wi-Fi-only reconfiguration sends an empty token, never a new ownership claim', async () => {
const { commands } = fake();
const client = new BLEClient();
await client.connect();
await client.configure({ ssid: 'HomeWiFi', password: 'test-only-password', token: '' });
assert.deepEqual(commands[1], { cmd: 'configure', wifi_ssid: 'HomeWiFi', wifi_password: 'test-only-password', bind_token: '' });
await assert.rejects(client.configure({ ssid: 'HomeWiFi', password: 'test-only-password', token: 'malformed' }));
assert.equal(commands.length, 2);
client.disconnect();
});
test('Factory reset uses a distinct BLE command with no binding token and validates Wi-Fi', async () => {
const {commands}=fake();const client=new BLEClient();await client.connect();
await client.factoryReset({ssid:'Home',password:'TestOnly123'});
assert.deepEqual(commands[1],{cmd:'factory_reset',wifi_ssid:'Home',wifi_password:'TestOnly123',bind_token:''});
await assert.rejects(client.factoryReset({ssid:'',password:'short'}));assert.equal(commands.length,2);client.disconnect();
});