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(); });