Browse Source
* Transport status events Add symbol docs Emit transport status events Transport test suite * Review fixes * Remove core dependency * HTTP transport use AbortSignal, error handling in TransportNode * Improve stream handling * Update packages/transport-web-serial/src/transport.ts Co-authored-by: Copilot <[email protected]> * Fix linting --------- Co-authored-by: philon- <[email protected]> Co-authored-by: Copilot <[email protected]>pull/802/head
committed by
GitHub
15 changed files with 1881 additions and 178 deletions
@ -0,0 +1,229 @@ |
|||
import { describe, vi, expect, it, beforeEach, afterEach, type MockInstance } from "vitest"; |
|||
import { runTransportContract } from "../../../tests/utils/transportContract"; |
|||
import { TransportHTTP } from "./transport"; |
|||
|
|||
let abortTimeoutSpy: MockInstance | undefined; |
|||
beforeEach(() => { |
|||
abortTimeoutSpy = vi.spyOn( |
|||
globalThis.AbortSignal as unknown as { timeout(ms: number): AbortSignal }, |
|||
"timeout", |
|||
).mockImplementation((ms: number) => { |
|||
const ctrl = new AbortController(); |
|||
const abort = () => |
|||
ctrl.abort(new DOMException("Timeout reached", "TimeoutError")); |
|||
// Uses setTimeout so vi.useFakeTimers() can fast-forward it
|
|||
setTimeout(abort, ms); |
|||
return ctrl.signal; |
|||
}); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
abortTimeoutSpy?.mockRestore(); |
|||
}); |
|||
|
|||
function stubFetch() { |
|||
const inbox: Uint8Array[] = []; |
|||
let lastWritten: ArrayBuffer | undefined; |
|||
|
|||
let forceNextReadToHang = false; |
|||
let forceNextReadToReturn500 = false; |
|||
|
|||
function makeAbortAwareHang(signal?: AbortSignal): Promise<Response> { |
|||
return new Promise((_, reject) => { |
|||
const abort = () => reject(new DOMException("Aborted", "AbortError")); |
|||
if (signal?.aborted) { |
|||
abort(); |
|||
return; |
|||
} |
|||
if (signal) { |
|||
signal.addEventListener("abort", abort, { once: true }); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => { |
|||
const method = (init?.method ?? "GET").toUpperCase(); |
|||
|
|||
if (url.includes("/api/v1/toradio") && method === "OPTIONS") { |
|||
return { ok: true, status: 204 } as Response; |
|||
} |
|||
|
|||
if (url.includes("/api/v1/toradio") && method === "PUT") { |
|||
lastWritten = init?.body as ArrayBuffer; |
|||
return { ok: true, status: 200 } as Response; |
|||
} |
|||
|
|||
if (url.includes("/api/v1/fromradio") && method === "GET") { |
|||
if (forceNextReadToHang) { |
|||
forceNextReadToHang = false; |
|||
return makeAbortAwareHang(init?.signal ?? undefined); |
|||
} |
|||
|
|||
if (forceNextReadToReturn500) { |
|||
forceNextReadToReturn500 = false; |
|||
return { |
|||
ok: false, |
|||
status: 500, |
|||
arrayBuffer: async () => new ArrayBuffer(0), |
|||
} as Response; |
|||
} |
|||
|
|||
const next = inbox.shift() ?? new Uint8Array(); |
|||
return { |
|||
ok: true, |
|||
status: 200, |
|||
arrayBuffer: async () => next.buffer, |
|||
} as Response; |
|||
} |
|||
|
|||
return { ok: true, status: 200 } as Response; |
|||
}); |
|||
|
|||
vi.stubGlobal("fetch", mockFetch); |
|||
|
|||
return { |
|||
pushIncoming: (u8: Uint8Array) => inbox.push(u8), |
|||
assertLastWritten: (u8: Uint8Array) => { |
|||
const got = new Uint8Array(lastWritten || new ArrayBuffer(0)); |
|||
expect(got).toEqual(u8); |
|||
}, |
|||
forceReadErrorOnce: () => { |
|||
forceNextReadToReturn500 = true; |
|||
}, |
|||
forceReadTimeoutOnce: () => { |
|||
forceNextReadToHang = true; |
|||
}, |
|||
getMock: () => mockFetch, |
|||
cleanup: () => vi.unstubAllGlobals(), |
|||
}; |
|||
} |
|||
|
|||
async function tickNextTimer() { |
|||
try { |
|||
await vi.advanceTimersToNextTimerAsync(); |
|||
} catch { |
|||
await new Promise((r) => setTimeout(r, 5)); |
|||
} |
|||
} |
|||
|
|||
describe("TransportHTTP (contract)", () => { |
|||
runTransportContract({ |
|||
name: "TransportHTTP", |
|||
setup: () => { |
|||
vi.useFakeTimers(); |
|||
}, |
|||
teardown: () => { |
|||
vi.useRealTimers(); |
|||
vi.restoreAllMocks(); |
|||
vi.unstubAllGlobals(); |
|||
}, |
|||
create: async () => { |
|||
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http = stubFetch(); |
|||
const transport = await TransportHTTP.create("127.0.0.1:80", false); |
|||
await tickNextTimer(); |
|||
return transport; |
|||
}, |
|||
pushIncoming: async (bytes) => { |
|||
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http.pushIncoming(bytes); |
|||
await tickNextTimer(); |
|||
}, |
|||
assertLastWritten: (bytes) => { |
|||
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http.assertLastWritten(bytes); |
|||
}, |
|||
triggerDisconnect: async () => { |
|||
(globalThis as unknown as { __http: ReturnType<typeof stubFetch> }).__http.forceReadErrorOnce(); |
|||
await tickNextTimer(); |
|||
}, |
|||
}); |
|||
}); |
|||
|
|||
describe("TransportHTTP (extras)", () => { |
|||
let httpStub: ReturnType<typeof stubFetch> | undefined; |
|||
|
|||
beforeEach(() => { |
|||
vi.useFakeTimers(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.useRealTimers(); |
|||
vi.restoreAllMocks(); |
|||
httpStub?.cleanup(); |
|||
httpStub = undefined; |
|||
}); |
|||
|
|||
async function createTransport(): Promise<TransportHTTP> { |
|||
httpStub = stubFetch(); |
|||
const transport = await TransportHTTP.create("127.0.0.1:80", false); |
|||
await tickNextTimer(); |
|||
return transport; |
|||
} |
|||
|
|||
async function advanceOnePoll() { |
|||
await tickNextTimer(); |
|||
} |
|||
|
|||
it("emits DeviceDisconnected with reason 'read-timeout' when GET /fromradio hangs", async () => { |
|||
const transport = await createTransport(); |
|||
const reader = transport.fromDevice.getReader(); |
|||
|
|||
httpStub!.forceReadTimeoutOnce(); |
|||
|
|||
await tickNextTimer(); |
|||
await vi.advanceTimersByTimeAsync(8000); |
|||
|
|||
let sawReadTimeout = false; |
|||
for (let i = 0; i < 6; i++) { |
|||
const { value } = await reader.read(); |
|||
if (value?.type === "status" && value.data.reason === "read-timeout") { |
|||
sawReadTimeout = true; |
|||
break; |
|||
} |
|||
} |
|||
expect(sawReadTimeout).toBe(true); |
|||
|
|||
reader.releaseLock(); |
|||
await transport.disconnect(); |
|||
}); |
|||
|
|||
it("stops polling after disconnect()", async () => { |
|||
const transport = await createTransport(); |
|||
|
|||
const fetchMock = httpStub!.getMock(); |
|||
const callsBeforeDisconnect = fetchMock.mock.calls.length; |
|||
|
|||
await transport.disconnect(); |
|||
|
|||
await advanceOnePoll(); |
|||
await vi.runOnlyPendingTimersAsync(); |
|||
|
|||
const callsAfterDisconnect = fetchMock.mock.calls.length; |
|||
expect(callsAfterDisconnect).toBe(callsBeforeDisconnect); |
|||
}); |
|||
|
|||
it("emits DeviceDisconnected with reason 'read-timeout' when GET /fromradio hangs", async () => { |
|||
const transport = await createTransport(); |
|||
const reader = transport.fromDevice.getReader(); |
|||
|
|||
httpStub!.forceReadTimeoutOnce(); |
|||
|
|||
await vi.advanceTimersToNextTimerAsync(); |
|||
|
|||
await vi.advanceTimersByTimeAsync(8000); |
|||
|
|||
await Promise.resolve(); |
|||
await Promise.resolve(); |
|||
|
|||
let sawReadTimeout = false; |
|||
for (let i = 0; i < 6; i++) { |
|||
const { value } = await reader.read(); |
|||
if (value?.type === "status" && value.data.reason === "read-timeout") { |
|||
sawReadTimeout = true; |
|||
break; |
|||
} |
|||
} |
|||
expect(sawReadTimeout).toBe(true); |
|||
|
|||
reader.releaseLock(); |
|||
await transport.disconnect(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,187 @@ |
|||
import { describe, vi, expect, beforeEach, afterEach, it } from "vitest"; |
|||
import { Duplex } from "node:stream"; |
|||
import type { SerialPort } from "serialport"; |
|||
import { Types, Utils } from "@meshtastic/core"; |
|||
import { runTransportContract } from "../../../tests/utils/transportContract"; |
|||
import { TransportNodeSerial } from "./transport"; |
|||
|
|||
function isStatusEvent( |
|||
output: Types.DeviceOutput | undefined, |
|||
): output is Extract<Types.DeviceOutput, { type: "status" }> { |
|||
return output !== undefined && output.type === "status"; |
|||
} |
|||
|
|||
class FakeSerialPort extends Duplex { |
|||
public lastWritten: Uint8Array | undefined; |
|||
|
|||
constructor() { |
|||
super({ objectMode: false }); |
|||
} |
|||
|
|||
_read() {} |
|||
|
|||
_write( |
|||
chunk: Buffer, |
|||
_encoding: BufferEncoding, |
|||
callback: (error?: Error | null) => void, |
|||
) { |
|||
this.lastWritten = new Uint8Array( |
|||
chunk.buffer, |
|||
chunk.byteOffset, |
|||
chunk.byteLength, |
|||
); |
|||
callback(); |
|||
} |
|||
|
|||
pushIncoming(data: Uint8Array) { |
|||
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); |
|||
this.push(buf); |
|||
} |
|||
|
|||
emitErrorOnce(message = "simulated serial error") { |
|||
this.emit("error", new Error(message)); |
|||
} |
|||
|
|||
emitClose() { |
|||
this.emit("close"); |
|||
} |
|||
|
|||
close() { |
|||
this.destroy(); |
|||
this.emit("close"); |
|||
} |
|||
} |
|||
|
|||
function stubCoreTransforms() { |
|||
const toDevice = new TransformStream<Uint8Array, Uint8Array>({ |
|||
transform(chunk, controller) { |
|||
controller.enqueue(chunk); |
|||
}, |
|||
}); |
|||
|
|||
const fromDeviceFactory = () => |
|||
new TransformStream<Uint8Array, Types.DeviceOutput>({ |
|||
transform(chunk, controller) { |
|||
controller.enqueue({ type: "packet", data: chunk }); |
|||
}, |
|||
}); |
|||
|
|||
// Utils.toDeviceStream is a getter
|
|||
vi.spyOn(Utils, "toDeviceStream", "get").mockReturnValue( |
|||
toDevice as unknown as typeof Utils.toDeviceStream, |
|||
); |
|||
|
|||
vi.spyOn(Utils, "fromDeviceStream").mockImplementation( |
|||
() => |
|||
fromDeviceFactory() as unknown as TransformStream< |
|||
Uint8Array, |
|||
Types.DeviceOutput |
|||
>, |
|||
); |
|||
|
|||
return { |
|||
restore: () => vi.restoreAllMocks(), |
|||
}; |
|||
} |
|||
|
|||
describe("TransportNodeSerial (contract)", () => { |
|||
let transformsStub: { restore: () => void } | undefined; |
|||
|
|||
beforeEach(() => { |
|||
transformsStub = stubCoreTransforms(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
transformsStub?.restore(); |
|||
}); |
|||
|
|||
runTransportContract({ |
|||
name: "TransportNodeSerial", |
|||
setup: () => {}, |
|||
teardown: () => { |
|||
vi.restoreAllMocks(); |
|||
}, |
|||
create: async () => { |
|||
const fakePort = new FakeSerialPort(); |
|||
const transport = new TransportNodeSerial( |
|||
fakePort as unknown as SerialPort, |
|||
); |
|||
await Promise.resolve(); |
|||
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort = |
|||
fakePort; |
|||
return transport; |
|||
}, |
|||
pushIncoming: async (bytes) => { |
|||
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.pushIncoming( |
|||
bytes, |
|||
); |
|||
await Promise.resolve(); |
|||
}, |
|||
assertLastWritten: (bytes) => { |
|||
const port = |
|||
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort; |
|||
expect(port.lastWritten).toBeDefined(); |
|||
expect(port.lastWritten).toEqual(bytes); |
|||
}, |
|||
triggerDisconnect: async () => { |
|||
(globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.emitErrorOnce( |
|||
"test-disconnect", |
|||
); |
|||
await Promise.resolve(); |
|||
}, |
|||
}); |
|||
}); |
|||
|
|||
describe("TransportNodeSerial (extras)", () => { |
|||
let transformsStub: { restore: () => void } | undefined; |
|||
|
|||
beforeEach(() => { |
|||
transformsStub = stubCoreTransforms(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
transformsStub?.restore(); |
|||
}); |
|||
|
|||
it("emits DeviceDisconnected with reason 'port-closed' on close event", async () => { |
|||
const fakePort = new FakeSerialPort(); |
|||
const transport = new TransportNodeSerial( |
|||
fakePort as unknown as SerialPort, |
|||
); |
|||
const reader = transport.fromDevice.getReader(); |
|||
|
|||
await Promise.resolve(); |
|||
|
|||
const first = await reader.read(); |
|||
expect(isStatusEvent(first.value)).toBe(true); |
|||
if (isStatusEvent(first.value)) { |
|||
expect(first.value.data.status).toBe( |
|||
Types.DeviceStatusEnum.DeviceConnecting, |
|||
); |
|||
} |
|||
|
|||
const second = await reader.read(); |
|||
expect(isStatusEvent(second.value)).toBe(true); |
|||
if (isStatusEvent(second.value)) { |
|||
expect(second.value.data.status).toBe( |
|||
Types.DeviceStatusEnum.DeviceConnected, |
|||
); |
|||
} |
|||
|
|||
fakePort.emitClose(); |
|||
await Promise.resolve(); |
|||
|
|||
let sawClosed = false; |
|||
for (let i = 0; i < 6; i++) { |
|||
const { value } = await reader.read(); |
|||
if (isStatusEvent(value) && value.data.reason === "port-closed") { |
|||
sawClosed = true; |
|||
break; |
|||
} |
|||
} |
|||
expect(sawClosed).toBe(true); |
|||
|
|||
reader.releaseLock(); |
|||
await transport.disconnect(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,186 @@ |
|||
import { describe, vi, expect, beforeEach, afterEach, it } from "vitest"; |
|||
import { Duplex } from "node:stream"; |
|||
import type { Socket } from "node:net"; |
|||
import { runTransportContract } from "../../../tests/utils/transportContract"; |
|||
import { TransportNode } from "./transport"; |
|||
import { Utils, Types } from "@meshtastic/core"; |
|||
|
|||
function isStatusEvent( |
|||
out: Types.DeviceOutput | undefined, |
|||
): out is Extract<Types.DeviceOutput, { type: "status" }> { |
|||
return !!out && (out as any).type === "status"; |
|||
} |
|||
|
|||
class FakeSocket extends Duplex { |
|||
public lastWritten: Uint8Array | undefined; |
|||
|
|||
constructor() { |
|||
super({ objectMode: false }); |
|||
} |
|||
|
|||
_read() {} |
|||
|
|||
_write( |
|||
chunk: Buffer, |
|||
_encoding: BufferEncoding, |
|||
callback: (error?: Error | null) => void, |
|||
) { |
|||
this.lastWritten = new Uint8Array( |
|||
chunk.buffer, |
|||
chunk.byteOffset, |
|||
chunk.byteLength, |
|||
); |
|||
callback(); |
|||
} |
|||
|
|||
pushIncoming(data: Uint8Array) { |
|||
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); |
|||
this.push(buf); |
|||
} |
|||
|
|||
emitErrorOnce(message = "simulated error") { |
|||
this.emit("error", new Error(message)); |
|||
} |
|||
|
|||
emitClose() { |
|||
this.emit("close"); |
|||
} |
|||
|
|||
override destroy(error?: Error) { |
|||
super.destroy(error); |
|||
this.emit("close"); |
|||
return this; |
|||
} |
|||
} |
|||
|
|||
function stubCoreTransforms() { |
|||
const toDevice = new TransformStream<Uint8Array, Uint8Array>({ |
|||
transform(chunk, controller) { |
|||
controller.enqueue(chunk); |
|||
}, |
|||
}); |
|||
|
|||
const fromDeviceFactory = () => |
|||
new TransformStream<Uint8Array, Types.DeviceOutput>({ |
|||
transform(chunk, controller) { |
|||
controller.enqueue({ type: "packet", data: chunk }); |
|||
}, |
|||
}); |
|||
|
|||
vi.spyOn(Utils, "toDeviceStream", "get").mockReturnValue( |
|||
toDevice as unknown as typeof Utils.toDeviceStream, |
|||
); |
|||
|
|||
vi |
|||
.spyOn(Utils, "fromDeviceStream") |
|||
.mockImplementation( |
|||
() => |
|||
fromDeviceFactory() as unknown as TransformStream< |
|||
Uint8Array, |
|||
Types.DeviceOutput |
|||
>, |
|||
); |
|||
|
|||
return { |
|||
restore: () => vi.restoreAllMocks(), |
|||
}; |
|||
} |
|||
|
|||
describe("TransportNode (contract)", () => { |
|||
let transformsStub: { restore: () => void } | undefined; |
|||
|
|||
beforeEach(() => { |
|||
transformsStub = stubCoreTransforms(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
transformsStub?.restore(); |
|||
}); |
|||
|
|||
runTransportContract({ |
|||
name: "TransportNode", |
|||
setup: () => {}, |
|||
teardown: () => { |
|||
vi.restoreAllMocks(); |
|||
}, |
|||
create: async () => { |
|||
const fakeSocket = new FakeSocket(); |
|||
const transport = new TransportNode(fakeSocket as unknown as Socket); |
|||
await Promise.resolve(); |
|||
(globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock = |
|||
fakeSocket; |
|||
return transport; |
|||
}, |
|||
pushIncoming: async (bytes) => { |
|||
(globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock.pushIncoming( |
|||
bytes, |
|||
); |
|||
await Promise.resolve(); |
|||
}, |
|||
assertLastWritten: (bytes) => { |
|||
const sock = (globalThis as unknown as { __nodeSock: FakeSocket }) |
|||
.__nodeSock; |
|||
expect(sock.lastWritten).toBeDefined(); |
|||
expect(sock.lastWritten).toEqual(bytes); |
|||
}, |
|||
triggerDisconnect: async () => { |
|||
(globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock.emitErrorOnce( |
|||
"test-disconnect", |
|||
); |
|||
await Promise.resolve(); |
|||
}, |
|||
}); |
|||
}); |
|||
|
|||
describe("TransportNode (extras)", () => { |
|||
let transformsStub: { restore: () => void } | undefined; |
|||
|
|||
beforeEach(() => { |
|||
transformsStub = stubCoreTransforms(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
transformsStub?.restore(); |
|||
}); |
|||
|
|||
it("emits DeviceDisconnected with reason 'socket-closed' on close event", async () => { |
|||
const fakeSocket = new FakeSocket(); |
|||
const transport = new TransportNode(fakeSocket as unknown as Socket); |
|||
const reader = transport.fromDevice.getReader(); |
|||
|
|||
await Promise.resolve(); |
|||
|
|||
const first = await reader.read(); |
|||
expect(isStatusEvent(first.value)).toBe(true); |
|||
if (isStatusEvent(first.value)) { |
|||
expect(first.value.data.status).toBe( |
|||
Types.DeviceStatusEnum.DeviceConnecting, |
|||
); |
|||
} |
|||
|
|||
const second = await reader.read(); |
|||
expect(isStatusEvent(second.value)).toBe(true); |
|||
if (isStatusEvent(second.value)) { |
|||
expect(second.value.data.status).toBe( |
|||
Types.DeviceStatusEnum.DeviceConnected, |
|||
); |
|||
} |
|||
|
|||
fakeSocket.emitClose(); |
|||
await Promise.resolve(); |
|||
|
|||
let sawClosed = false; |
|||
for (let i = 0; i < 6; i++) { |
|||
const { value } = await reader.read(); |
|||
if (isStatusEvent(value) && value.data.reason === "socket-closed") { |
|||
sawClosed = true; |
|||
break; |
|||
} |
|||
} |
|||
expect(sawClosed).toBe(true); |
|||
|
|||
reader.releaseLock(); |
|||
await transport.disconnect(); |
|||
}); |
|||
|
|||
}); |
|||
@ -0,0 +1,166 @@ |
|||
import { describe, vi, expect, beforeEach, afterEach } from "vitest"; |
|||
import { runTransportContract } from "../../../tests/utils/transportContract"; |
|||
import { TransportWebBluetooth } from "./transport"; |
|||
|
|||
class MiniEmitter { |
|||
private listeners = new Map<string, Set<(e: Event) => void>>(); |
|||
addEventListener(type: string, listener: (e: Event) => void) { |
|||
if (!this.listeners.has(type)) this.listeners.set(type, new Set()); |
|||
this.listeners.get(type)!.add(listener); |
|||
} |
|||
removeEventListener(type: string, listener: (e: Event) => void) { |
|||
this.listeners.get(type)?.delete(listener); |
|||
} |
|||
dispatchEvent(event: Event) { |
|||
this.listeners.get(event.type)?.forEach((l) => l(event)); |
|||
} |
|||
} |
|||
|
|||
function stubWebBluetooth() { |
|||
const incomingQueue: Uint8Array[] = []; |
|||
let lastWritten: Uint8Array | undefined; |
|||
|
|||
// fromRadioCharacteristic: read bytes from queue, one buffer per read
|
|||
const fromRadioCharacteristic: BluetoothRemoteGATTCharacteristic = { |
|||
async readValue() { |
|||
const next = incomingQueue.shift() ?? new Uint8Array(); |
|||
return new DataView( |
|||
next.buffer, |
|||
next.byteOffset, |
|||
next.byteLength, |
|||
) as unknown as DataView; |
|||
}, |
|||
addEventListener() {}, |
|||
removeEventListener() {}, |
|||
} as unknown as BluetoothRemoteGATTCharacteristic; |
|||
|
|||
// characteristicvaluechanged event plumbing (fromNumCharacteristic)
|
|||
const charEmitter = new MiniEmitter(); |
|||
|
|||
const fromNumCharacteristic: BluetoothRemoteGATTCharacteristic = { |
|||
async startNotifications() { |
|||
return this; |
|||
}, |
|||
addEventListener(type: string, listener: (e: Event) => void) { |
|||
charEmitter.addEventListener(type, listener); |
|||
}, |
|||
removeEventListener(type: string, listener: (e: Event) => void) { |
|||
charEmitter.removeEventListener(type, listener); |
|||
}, |
|||
} as unknown as BluetoothRemoteGATTCharacteristic; |
|||
|
|||
const toRadioCharacteristic: BluetoothRemoteGATTCharacteristic = { |
|||
async writeValue(bufferSource: BufferSource) { |
|||
const u8 = |
|||
bufferSource instanceof ArrayBuffer |
|||
? new Uint8Array(bufferSource) |
|||
: new Uint8Array( |
|||
bufferSource.buffer, |
|||
bufferSource.byteOffset, |
|||
bufferSource.byteLength, |
|||
); |
|||
lastWritten = new Uint8Array(u8); |
|||
}, |
|||
} as unknown as BluetoothRemoteGATTCharacteristic; |
|||
|
|||
// Primary service returns our three characteristics by UUID
|
|||
const primaryService: BluetoothRemoteGATTService = { |
|||
async getCharacteristic(uuid: string) { |
|||
if (uuid === TransportWebBluetooth.ToRadioUuid) return toRadioCharacteristic; |
|||
if (uuid === TransportWebBluetooth.FromRadioUuid) return fromRadioCharacteristic; |
|||
if (uuid === TransportWebBluetooth.FromNumUuid) return fromNumCharacteristic; |
|||
throw new Error("Unknown characteristic: " + uuid); |
|||
}, |
|||
} as unknown as BluetoothRemoteGATTService; |
|||
|
|||
// Device-level emitter to deliver gattserverdisconnected
|
|||
const deviceEmitter = new MiniEmitter(); |
|||
|
|||
// GATT server with readonly connected
|
|||
let isConnected = true; |
|||
const gattServer: BluetoothRemoteGATTServer = { |
|||
get connected() { |
|||
return isConnected; |
|||
}, |
|||
async connect() { |
|||
isConnected = true; |
|||
return gattServer; |
|||
}, |
|||
disconnect() { |
|||
isConnected = false; |
|||
deviceEmitter.dispatchEvent(new Event("gattserverdisconnected")); |
|||
}, |
|||
async getPrimaryService() { |
|||
return primaryService; |
|||
}, |
|||
device: { |
|||
addEventListener: (...args: Parameters<EventTarget["addEventListener"]>) => |
|||
deviceEmitter.addEventListener(args[0] as string, args[1] as (e: Event) => void), |
|||
removeEventListener: (...args: Parameters<EventTarget["removeEventListener"]>) => |
|||
deviceEmitter.removeEventListener(args[0] as string, args[1] as (e: Event) => void), |
|||
} as unknown as BluetoothDevice, |
|||
} as unknown as BluetoothRemoteGATTServer; |
|||
|
|||
const fakeDevice: BluetoothDevice = { |
|||
async watchAdvertisements() {}, |
|||
gatt: gattServer, |
|||
} as unknown as BluetoothDevice; |
|||
|
|||
const fakeNavigator = { |
|||
bluetooth: { |
|||
async requestDevice() { |
|||
return fakeDevice; |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
vi.stubGlobal("navigator", Object.assign({}, globalThis.navigator, fakeNavigator)); |
|||
|
|||
// helper actions for tests/contract
|
|||
return { |
|||
pushIncoming: (u8: Uint8Array) => { |
|||
incomingQueue.push(u8); |
|||
charEmitter.dispatchEvent(new Event("characteristicvaluechanged")); |
|||
}, |
|||
assertLastWritten: (u8: Uint8Array) => { |
|||
expect(lastWritten).toBeDefined(); |
|||
expect(lastWritten).toEqual(u8); |
|||
}, |
|||
// simulate underlying link drop (OS-level disconnect)
|
|||
triggerGattDisconnect: () => { |
|||
isConnected = false; |
|||
deviceEmitter.dispatchEvent(new Event("gattserverdisconnected")); |
|||
}, |
|||
cleanup: () => { |
|||
vi.unstubAllGlobals(); |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
describe("TransportWebBluetooth (contract)", () => { |
|||
runTransportContract({ |
|||
name: "TransportWebBluetooth", |
|||
setup: () => {}, |
|||
teardown: () => { |
|||
(globalThis as unknown as { __ble?: ReturnType<typeof stubWebBluetooth> }).__ble?.cleanup(); |
|||
(globalThis as unknown as { __ble?: ReturnType<typeof stubWebBluetooth> }).__ble = undefined; |
|||
vi.restoreAllMocks(); |
|||
vi.unstubAllGlobals(); |
|||
}, |
|||
create: async () => { |
|||
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble = stubWebBluetooth(); |
|||
return await TransportWebBluetooth.create(); |
|||
}, |
|||
pushIncoming: async (bytes) => { |
|||
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.pushIncoming(bytes); |
|||
await Promise.resolve(); |
|||
}, |
|||
assertLastWritten: (bytes) => { |
|||
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.assertLastWritten(bytes); |
|||
}, |
|||
triggerDisconnect: async () => { |
|||
(globalThis as unknown as { __ble: ReturnType<typeof stubWebBluetooth> }).__ble.triggerGattDisconnect(); |
|||
await Promise.resolve(); |
|||
}, |
|||
}); |
|||
}); |
|||
@ -0,0 +1,225 @@ |
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; |
|||
import { TransportWebSerial } from "./transport"; |
|||
import { Types, Utils } from "@meshtastic/core"; |
|||
import { runTransportContract } from "../../../tests/utils/transportContract"; |
|||
|
|||
function stubCoreTransforms() { |
|||
const toDevice = new TransformStream<Uint8Array, Uint8Array>({ |
|||
transform(chunk, controller) { |
|||
controller.enqueue(chunk); |
|||
}, |
|||
}); |
|||
|
|||
// maps raw bytes -> DeviceOutput.packet
|
|||
const fromDeviceFactory = () => |
|||
new TransformStream<Uint8Array, Types.DeviceOutput>({ |
|||
transform(chunk, controller) { |
|||
controller.enqueue({ type: "packet", data: chunk }); |
|||
}, |
|||
}); |
|||
|
|||
const restoreTo = vi |
|||
.spyOn(Utils, "toDeviceStream", "get") |
|||
.mockReturnValue(toDevice as unknown as typeof Utils.toDeviceStream); |
|||
|
|||
const restoreFrom = vi |
|||
.spyOn(Utils, "fromDeviceStream") |
|||
.mockImplementation( |
|||
() => |
|||
fromDeviceFactory() as unknown as TransformStream< |
|||
Uint8Array, |
|||
Types.DeviceOutput |
|||
>, |
|||
); |
|||
|
|||
return { |
|||
restore: () => { |
|||
restoreTo.mockRestore(); |
|||
restoreFrom.mockRestore(); |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
function stubNavigatorSerial() { |
|||
type SerialDisconnectHandler = (ev: { port?: any }) => void; |
|||
const handlers = new Set<SerialDisconnectHandler>(); |
|||
|
|||
const serialStub = { |
|||
addEventListener: (type: string, handler: EventListenerOrEventListenerObject) => { |
|||
if (type === "disconnect") handlers.add(handler as any as SerialDisconnectHandler); |
|||
}, |
|||
removeEventListener: (type: string, handler: EventListenerOrEventListenerObject) => { |
|||
if (type === "disconnect") handlers.delete(handler as any as SerialDisconnectHandler); |
|||
}, |
|||
dispatchDisconnect(port: any) { |
|||
for (const h of handlers) h({ port }); |
|||
}, |
|||
requestPort: vi.fn(async () => new FakeSerialPort()), |
|||
}; |
|||
|
|||
const nav: any = (globalThis as any).navigator ?? {}; |
|||
const hadNavigator = !!(globalThis as any).navigator; |
|||
const originalSerial = nav.serial; |
|||
|
|||
if (!hadNavigator) { |
|||
Object.defineProperty(globalThis as any, "navigator", { |
|||
value: nav, |
|||
configurable: true, |
|||
writable: false, |
|||
}); |
|||
} |
|||
|
|||
Object.defineProperty(nav, "serial", { |
|||
value: serialStub, |
|||
configurable: true, |
|||
enumerable: true, |
|||
writable: true, |
|||
}); |
|||
|
|||
return { |
|||
serialStub, |
|||
restore: () => { |
|||
if (hadNavigator) { |
|||
if (originalSerial === undefined) { |
|||
delete (globalThis as any).navigator.serial; |
|||
} else { |
|||
Object.defineProperty((globalThis as any).navigator, "serial", { |
|||
value: originalSerial, |
|||
configurable: true, |
|||
enumerable: true, |
|||
writable: true, |
|||
}); |
|||
} |
|||
} else { |
|||
delete (globalThis as any).navigator; |
|||
} |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
class FakeSerialPort { |
|||
readable: ReadableStream<Uint8Array>; |
|||
writable: WritableStream<Uint8Array>; |
|||
lastWritten?: Uint8Array; |
|||
|
|||
private _readController!: ReadableStreamDefaultController<Uint8Array>; |
|||
|
|||
constructor() { |
|||
this.readable = new ReadableStream<Uint8Array>({ |
|||
start: (controller) => { |
|||
this._readController = controller; |
|||
}, |
|||
}); |
|||
|
|||
this.writable = new WritableStream<Uint8Array>({ |
|||
write: async (chunk) => { |
|||
this.lastWritten = chunk; |
|||
}, |
|||
}); |
|||
} |
|||
|
|||
open(_options?: { baudRate?: number }): Promise<void> { |
|||
return Promise.resolve(); |
|||
} |
|||
|
|||
close(): Promise<void> { |
|||
try { |
|||
this._readController.close(); |
|||
} catch {} |
|||
return Promise.resolve(); |
|||
} |
|||
|
|||
pushIncoming(bytes: Uint8Array) { |
|||
this._readController.enqueue(bytes); |
|||
} |
|||
} |
|||
|
|||
describe("TransportWebSerial (contract)", () => { |
|||
let transforms: { restore(): void } | undefined; |
|||
let navSerial: { serialStub: any; restore(): void } | undefined; |
|||
|
|||
beforeEach(() => { |
|||
transforms = stubCoreTransforms(); |
|||
navSerial = stubNavigatorSerial(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
transforms?.restore(); |
|||
navSerial?.restore(); |
|||
vi.restoreAllMocks(); |
|||
}); |
|||
|
|||
runTransportContract({ |
|||
name: "TransportWebSerial", |
|||
setup: () => {}, |
|||
teardown: () => {}, |
|||
create: async () => { |
|||
const fake = new FakeSerialPort(); |
|||
const transport = await TransportWebSerial.createFromPort(fake as any); |
|||
(globalThis as any).__ws = { fake, serial: navSerial!.serialStub }; |
|||
await Promise.resolve(); |
|||
return transport; |
|||
}, |
|||
pushIncoming: async (bytes) => { |
|||
(globalThis as any).__ws.fake.pushIncoming(bytes); |
|||
await Promise.resolve(); |
|||
}, |
|||
assertLastWritten: (bytes) => { |
|||
expect((globalThis as any).__ws.fake.lastWritten).toEqual(bytes); |
|||
}, |
|||
triggerDisconnect: async () => { |
|||
(globalThis as any).__ws.serial.dispatchDisconnect( |
|||
(globalThis as any).__ws.fake, |
|||
); |
|||
await Promise.resolve(); |
|||
}, |
|||
}); |
|||
}); |
|||
|
|||
describe("TransportWebSerial (extras)", () => { |
|||
let transforms: { restore(): void } | undefined; |
|||
let navSerial: { serialStub: any; restore(): void } | undefined; |
|||
|
|||
beforeEach(() => { |
|||
transforms = stubCoreTransforms(); |
|||
navSerial = stubNavigatorSerial(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
transforms?.restore(); |
|||
navSerial?.restore(); |
|||
vi.restoreAllMocks(); |
|||
}); |
|||
|
|||
it("emits DeviceDisconnected('serial-disconnected') on OS disconnect event", async () => { |
|||
const fake = new FakeSerialPort(); |
|||
const transport = await TransportWebSerial.createFromPort(fake as any); |
|||
(globalThis as any).__ws = { fake, serial: navSerial!.serialStub }; |
|||
|
|||
const reader = transport.fromDevice.getReader(); |
|||
|
|||
// drain statuses until connected
|
|||
for (let i = 0; i < 3; i++) { |
|||
const { value } = await reader.read(); |
|||
if (!value || value.type !== "status") break; |
|||
if (value.data.status === Types.DeviceStatusEnum.DeviceConnected) break; |
|||
} |
|||
|
|||
// fire OS-level disconnect
|
|||
navSerial!.serialStub.dispatchDisconnect(fake as any); |
|||
await Promise.resolve(); |
|||
|
|||
let saw = false; |
|||
for (let i = 0; i < 6; i++) { |
|||
const { value } = await reader.read(); |
|||
if (value?.type === "status" && value.data.reason === "serial-disconnected") { |
|||
saw = true; |
|||
break; |
|||
} |
|||
} |
|||
expect(saw).toBe(true); |
|||
|
|||
reader.releaseLock(); |
|||
await transport.disconnect(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,122 @@ |
|||
import { Types } from "@meshtastic/core"; |
|||
import { describe, expect, it } from "vitest"; |
|||
|
|||
export interface TransportContract { |
|||
name: string; |
|||
create: () => Promise<Types.Transport>; |
|||
setup?: () => void | Promise<void>; |
|||
teardown?: () => void | Promise<void>; |
|||
pushIncoming?: (bytes: Uint8Array) => void | Promise<void>; |
|||
assertLastWritten?: (bytes: Uint8Array) => void; |
|||
triggerDisconnect?: () => void | Promise<void>; |
|||
} |
|||
|
|||
async function readUntilType( |
|||
reader: ReadableStreamDefaultReader<Types.DeviceOutput>, |
|||
expectedType: Types.DeviceOutput["type"], |
|||
maxReads = 20, |
|||
): Promise<Types.DeviceOutput> { |
|||
for (let i = 0; i < maxReads; i++) { |
|||
const { value, done } = await reader.read(); |
|||
if (done) { |
|||
break; |
|||
} |
|||
if (value && value.type === expectedType) { |
|||
return value; |
|||
} |
|||
} |
|||
throw new Error( |
|||
`Did not receive a '${expectedType}' event within ${maxReads} reads`, |
|||
); |
|||
} |
|||
|
|||
export function runTransportContract(contract: TransportContract) { |
|||
describe(contract.name, () => { |
|||
it("reads packets from fromDevice", async () => { |
|||
await contract.setup?.(); |
|||
const transport = await contract.create(); |
|||
|
|||
const reader = transport.fromDevice.getReader(); |
|||
const sampleBytes = new Uint8Array([0x01, 0x02, 0x03]); |
|||
|
|||
await contract.pushIncoming?.(sampleBytes); |
|||
|
|||
const packetEvent = await readUntilType(reader, "packet"); |
|||
expect("data" in packetEvent ? packetEvent.data : undefined).toEqual( |
|||
sampleBytes, |
|||
); |
|||
|
|||
reader.releaseLock(); |
|||
await contract.teardown?.(); |
|||
}); |
|||
|
|||
it("writes bytes to toDevice", async () => { |
|||
await contract.setup?.(); |
|||
const transport = await contract.create(); |
|||
|
|||
const writer = transport.toDevice.getWriter(); |
|||
const outgoingBytes = new Uint8Array([0xaa, 0xbb]); |
|||
await writer.write(outgoingBytes); |
|||
await writer.close(); |
|||
|
|||
contract.assertLastWritten?.(outgoingBytes); |
|||
await contract.teardown?.(); |
|||
}); |
|||
|
|||
it("disconnect() emits DeviceDisconnected('user')", async () => { |
|||
await contract.setup?.(); |
|||
const transport = await contract.create(); |
|||
|
|||
const reader = transport.fromDevice.getReader(); |
|||
|
|||
// Trigger user disconnect
|
|||
await transport.disconnect(); |
|||
|
|||
// Read a few events and assert we eventually see the user disconnect.
|
|||
let sawUser = false; |
|||
for (let i = 0; i < 10; i++) { |
|||
const { value } = await reader.read(); |
|||
if ( |
|||
value && |
|||
value.type === "status" && |
|||
value.data.status === Types.DeviceStatusEnum.DeviceDisconnected && |
|||
value.data.reason === "user" |
|||
) { |
|||
sawUser = true; |
|||
break; |
|||
} |
|||
} |
|||
expect(sawUser).toBe(true); |
|||
|
|||
reader.releaseLock(); |
|||
await contract.teardown?.(); |
|||
}); |
|||
|
|||
it("emits DeviceDisconnected when the underlying link drops", async () => { |
|||
await contract.setup?.(); |
|||
const transport = await contract.create(); |
|||
|
|||
const reader = transport.fromDevice.getReader(); |
|||
|
|||
await contract.triggerDisconnect?.(); |
|||
|
|||
// As above, read a few events and assert we eventually see "disconnected"
|
|||
let sawDrop = false; |
|||
for (let i = 0; i < 10; i++) { |
|||
const { value } = await reader.read(); |
|||
if ( |
|||
value && |
|||
value.type === "status" && |
|||
value.data.status === Types.DeviceStatusEnum.DeviceDisconnected |
|||
) { |
|||
sawDrop = true; |
|||
break; |
|||
} |
|||
} |
|||
expect(sawDrop).toBe(true); |
|||
|
|||
reader.releaseLock(); |
|||
await contract.teardown?.(); |
|||
}); |
|||
}); |
|||
} |
|||
Loading…
Reference in new issue