You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

225 lines
6.1 KiB

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