diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 583885f6..c00b60c5 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -130,15 +130,30 @@ importers: specifier: workspace:^0.0.24 version: link:../struct devDependencies: + '@jest/globals': + specifier: ^30.0.0-alpha.4 + version: 30.0.0-alpha.5 + '@types/node': + specifier: ^20.14.9 + version: 20.14.9 '@yume-chan/eslint-config': specifier: workspace:^1.0.0 version: link:../../toolchain/eslint-config '@yume-chan/tsconfig': specifier: workspace:^1.0.0 version: link:../../toolchain/tsconfig + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + jest: + specifier: ^30.0.0-alpha.4 + version: 30.0.0-alpha.5(@types/node@20.14.9) prettier: specifier: ^3.3.2 version: 3.3.2 + ts-jest: + specifier: ^29.1.5 + version: 29.1.5(@babel/core@7.24.7)(@jest/types@29.6.3)(jest@30.0.0-alpha.5)(typescript@5.5.2) typescript: specifier: ^5.5.2 version: 5.5.2 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index cdac9f5e..33219c8e 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "4268d369f3d5b02e089540e98cb1fa9074cba8c9", + "pnpmShrinkwrapHash": "e045703b662b53e585b409888c662653aa4196da", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/libraries/adb-credential-web/README.md b/libraries/adb-credential-web/README.md index 9437b8e8..c7f55597 100644 --- a/libraries/adb-credential-web/README.md +++ b/libraries/adb-credential-web/README.md @@ -15,8 +15,11 @@ GitHub release + + Package Size + - npm + npm Discord diff --git a/libraries/adb-daemon-webusb/README.md b/libraries/adb-daemon-webusb/README.md index bf2e3676..734f9faa 100644 --- a/libraries/adb-daemon-webusb/README.md +++ b/libraries/adb-daemon-webusb/README.md @@ -15,8 +15,11 @@ GitHub release - - npm + + Package Size + + + npm Discord diff --git a/libraries/adb-daemon-webusb/jest.config.js b/libraries/adb-daemon-webusb/jest.config.js new file mode 100644 index 00000000..ff68d1cb --- /dev/null +++ b/libraries/adb-daemon-webusb/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: "ts-jest/presets/default-esm", + extensionsToTreatAsEsm: [".ts"], + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { tsconfig: "tsconfig.test.json", useESM: true }, + ], + }, + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, +}; diff --git a/libraries/adb-daemon-webusb/package.json b/libraries/adb-daemon-webusb/package.json index b0a7c842..ace841cc 100644 --- a/libraries/adb-daemon-webusb/package.json +++ b/libraries/adb-daemon-webusb/package.json @@ -28,7 +28,8 @@ "build": "tsc -b tsconfig.build.json", "build:watch": "tsc -b tsconfig.build.json", "lint": "run-eslint && prettier src/**/*.ts --write --tab-width 4", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" TS_JEST_DISABLE_VER_CHECKER=true jest --coverage" }, "dependencies": { "@types/w3c-web-usb": "^1.0.10", @@ -37,9 +38,14 @@ "@yume-chan/struct": "workspace:^0.0.24" }, "devDependencies": { + "@jest/globals": "^30.0.0-alpha.4", + "@types/node": "^20.14.9", "@yume-chan/eslint-config": "workspace:^1.0.0", "@yume-chan/tsconfig": "workspace:^1.0.0", + "cross-env": "^7.0.3", + "jest": "^30.0.0-alpha.4", "prettier": "^3.3.2", + "ts-jest": "^29.1.5", "typescript": "^5.5.2" } } diff --git a/libraries/adb-daemon-webusb/src/device.ts b/libraries/adb-daemon-webusb/src/device.ts index 8cc42678..ca34f69c 100644 --- a/libraries/adb-daemon-webusb/src/device.ts +++ b/libraries/adb-daemon-webusb/src/device.ts @@ -22,9 +22,10 @@ import { import type { ExactReadable } from "@yume-chan/struct"; import { EMPTY_UINT8_ARRAY } from "@yume-chan/struct"; -import type { AdbDeviceFilter } from "./utils.js"; +import type { UsbInterfaceFilter } from "./utils.js"; import { findUsbAlternateInterface, + findUsbEndpoints, getSerialNumber, isErrorName, } from "./utils.js"; @@ -32,49 +33,30 @@ import { /** * The default filter for ADB devices, as defined by Google. */ -export const ADB_DEFAULT_DEVICE_FILTER = { +export const ADB_DEFAULT_INTERFACE_FILTER = { classCode: 0xff, subclassCode: 0x42, protocolCode: 1, -} as const satisfies AdbDeviceFilter; +} as const satisfies UsbInterfaceFilter; -/** - * Find the first pair of input and output endpoints from an alternate interface. - * - * ADB interface only has two endpoints, one for input and one for output. - */ -function findUsbEndpoints(endpoints: USBEndpoint[]) { - if (endpoints.length === 0) { - throw new TypeError("No endpoints given"); +export function toAdbDeviceFilters( + filters: USBDeviceFilter[] | undefined, +): (USBDeviceFilter & UsbInterfaceFilter)[] { + if (!filters || filters.length === 0) { + return [ADB_DEFAULT_INTERFACE_FILTER]; + } else { + return filters.map((filter) => ({ + ...filter, + classCode: + filter.classCode ?? ADB_DEFAULT_INTERFACE_FILTER.classCode, + subclassCode: + filter.subclassCode ?? + ADB_DEFAULT_INTERFACE_FILTER.subclassCode, + protocolCode: + filter.protocolCode ?? + ADB_DEFAULT_INTERFACE_FILTER.protocolCode, + })); } - - let inEndpoint: USBEndpoint | undefined; - let outEndpoint: USBEndpoint | undefined; - - for (const endpoint of endpoints) { - switch (endpoint.direction) { - case "in": - inEndpoint = endpoint; - if (outEndpoint) { - return { inEndpoint, outEndpoint }; - } - break; - case "out": - outEndpoint = endpoint; - if (inEndpoint) { - return { inEndpoint, outEndpoint }; - } - break; - } - } - - if (!inEndpoint) { - throw new TypeError("No input endpoint found."); - } - if (!outEndpoint) { - throw new TypeError("No output endpoint found."); - } - throw new Error("unreachable"); } class Uint8ArrayExactReadable implements ExactReadable { @@ -282,7 +264,7 @@ export class AdbDaemonWebUsbConnection } export class AdbDaemonWebUsbDevice implements AdbDaemonDevice { - #filters: AdbDeviceFilter[]; + #filters: UsbInterfaceFilter[]; #usbManager: USB; #raw: USBDevice; @@ -307,7 +289,7 @@ export class AdbDaemonWebUsbDevice implements AdbDaemonDevice { */ constructor( device: USBDevice, - filters: AdbDeviceFilter[] = [ADB_DEFAULT_DEVICE_FILTER], + filters: UsbInterfaceFilter[], usbManager: USB, ) { this.#raw = device; diff --git a/libraries/adb-daemon-webusb/src/manager.spec.ts b/libraries/adb-daemon-webusb/src/manager.spec.ts new file mode 100644 index 00000000..029a50c4 --- /dev/null +++ b/libraries/adb-daemon-webusb/src/manager.spec.ts @@ -0,0 +1,167 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/require-await */ +import { describe, expect, it, jest } from "@jest/globals"; + +import { ADB_DEFAULT_INTERFACE_FILTER, AdbDaemonWebUsbDevice } from "./device"; +import { AdbDaemonWebUsbDeviceManager } from "./manager.js"; + +class MockUsb implements USB { + onconnect: (ev: USBConnectionEvent) => void = jest.fn(); + ondisconnect: (ev: USBConnectionEvent) => void = jest.fn(); + + getDevices: () => Promise = jest.fn(async () => []); + requestDevice: (options?: USBDeviceRequestOptions) => Promise = + jest.fn(async () => ({ serialNumber: "abcdefgh" }) as never); + + addEventListener( + type: "connect" | "disconnect", + listener: (this: this, ev: USBConnectionEvent) => void, + useCapture?: boolean, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + _type: unknown, + _listener: unknown, + _options?: unknown, + ): void { + throw new Error("Method not implemented."); + } + removeEventListener( + type: "connect" | "disconnect", + callback: (this: this, ev: USBConnectionEvent) => void, + useCapture?: boolean, + ): void; + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: EventListenerOptions | boolean, + ): void; + removeEventListener( + _type: unknown, + _callback: unknown, + _options?: unknown, + ): void { + throw new Error("Method not implemented."); + } + dispatchEvent(_event: Event): boolean { + throw new Error("Method not implemented."); + } +} + +describe("AdbDaemonWebUsbDeviceManager", () => { + describe("requestDevice", () => { + it("should accept 0 args", async () => { + const usb = new MockUsb(); + const manager = new AdbDaemonWebUsbDeviceManager(usb); + await expect(manager.requestDevice()).resolves.toBeInstanceOf( + AdbDaemonWebUsbDevice, + ); + expect(usb.requestDevice).toHaveBeenCalledTimes(1); + expect(usb.requestDevice).toHaveBeenCalledWith({ + filters: [ADB_DEFAULT_INTERFACE_FILTER], + }); + }); + + it("should accept undefined filters", async () => { + const usb = new MockUsb(); + const manager = new AdbDaemonWebUsbDeviceManager(usb); + await expect( + manager.requestDevice({ filters: undefined }), + ).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice); + expect(usb.requestDevice).toHaveBeenCalledTimes(1); + expect(usb.requestDevice).toHaveBeenCalledWith({ + filters: [ADB_DEFAULT_INTERFACE_FILTER], + }); + }); + + it("should accept empty filters array", async () => { + const usb = new MockUsb(); + const manager = new AdbDaemonWebUsbDeviceManager(usb); + await expect( + manager.requestDevice({ filters: [] }), + ).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice); + expect(usb.requestDevice).toHaveBeenCalledTimes(1); + expect(usb.requestDevice).toHaveBeenCalledWith({ + filters: [ADB_DEFAULT_INTERFACE_FILTER], + }); + }); + + it("should accept empty filter object", async () => { + const usb = new MockUsb(); + const manager = new AdbDaemonWebUsbDeviceManager(usb); + await expect( + manager.requestDevice({ filters: [{}] }), + ).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice); + expect(usb.requestDevice).toHaveBeenCalledTimes(1); + expect(usb.requestDevice).toHaveBeenCalledWith({ + filters: [ADB_DEFAULT_INTERFACE_FILTER], + }); + }); + + it("should merge missing fields with default values", async () => { + const usb = new MockUsb(); + const manager = new AdbDaemonWebUsbDeviceManager(usb); + const filter: USBDeviceFilter = { vendorId: 0x1234 }; + await expect( + manager.requestDevice({ filters: [filter] }), + ).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice); + expect(usb.requestDevice).toHaveBeenCalledTimes(1); + expect(usb.requestDevice).toHaveBeenCalledWith({ + filters: [ + { + ...ADB_DEFAULT_INTERFACE_FILTER, + ...filter, + }, + ], + }); + }); + + it("should merge undefined fields with default values", async () => { + const usb = new MockUsb(); + const manager = new AdbDaemonWebUsbDeviceManager(usb); + const filter: USBDeviceFilter = { + classCode: undefined, + vendorId: 0x1234, + }; + await expect( + manager.requestDevice({ filters: [filter] }), + ).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice); + expect(usb.requestDevice).toHaveBeenCalledTimes(1); + expect(usb.requestDevice).toHaveBeenCalledWith({ + filters: [ + { + ...filter, + ...ADB_DEFAULT_INTERFACE_FILTER, + }, + ], + }); + }); + + it("should accept multiple filters", async () => { + const usb = new MockUsb(); + const manager = new AdbDaemonWebUsbDeviceManager(usb); + const filter1: USBDeviceFilter = { vendorId: 0x1234 }; + const filter2: USBDeviceFilter = { classCode: 0xaa }; + await expect( + manager.requestDevice({ filters: [filter1, filter2] }), + ).resolves.toBeInstanceOf(AdbDaemonWebUsbDevice); + expect(usb.requestDevice).toHaveBeenCalledTimes(1); + expect(usb.requestDevice).toHaveBeenCalledWith({ + filters: [ + { + ...ADB_DEFAULT_INTERFACE_FILTER, + ...filter1, + }, + { + ...ADB_DEFAULT_INTERFACE_FILTER, + ...filter2, + }, + ], + }); + }); + }); +}); diff --git a/libraries/adb-daemon-webusb/src/manager.ts b/libraries/adb-daemon-webusb/src/manager.ts index 7583298d..75d2f218 100644 --- a/libraries/adb-daemon-webusb/src/manager.ts +++ b/libraries/adb-daemon-webusb/src/manager.ts @@ -1,5 +1,4 @@ -import { ADB_DEFAULT_DEVICE_FILTER, AdbDaemonWebUsbDevice } from "./device.js"; -import type { AdbDeviceFilter } from "./utils.js"; +import { AdbDaemonWebUsbDevice, toAdbDeviceFilters } from "./device.js"; import { findUsbAlternateInterface, getSerialNumber, @@ -8,7 +7,7 @@ import { export namespace AdbDaemonWebUsbDeviceManager { export interface RequestDeviceOptions { - filters?: AdbDeviceFilter[] | undefined; + filters?: USBDeviceFilter[] | undefined; exclusionFilters?: USBDeviceFilter[] | undefined; } } @@ -44,28 +43,21 @@ export class AdbDaemonWebUsbDeviceManager { * It must have `classCode`, `subclassCode` and `protocolCode` fields for selecting the ADB interface, * but might also have `vendorId`, `productId` or `serialNumber` fields to limit the displayed device list. * - * Defaults to {@link ADB_DEFAULT_DEVICE_FILTER}. + * Defaults to {@link ADB_DEFAULT_INTERFACE_FILTER}. * @returns An {@link AdbDaemonWebUsbDevice} instance if the user selected a device, * or `undefined` if the user cancelled the device picker. */ async requestDevice( options: AdbDaemonWebUsbDeviceManager.RequestDeviceOptions = {}, ): Promise { - if (!options.filters) { - options.filters = [ADB_DEFAULT_DEVICE_FILTER]; - } else if (options.filters.length === 0) { - throw new TypeError("filters must not be empty"); - } + const filters = toAdbDeviceFilters(options.filters); try { - const device = await this.#usbManager.requestDevice( - options as USBDeviceRequestOptions, - ); - return new AdbDaemonWebUsbDevice( - device, - options.filters, - this.#usbManager, - ); + const device = await this.#usbManager.requestDevice({ + filters, + exclusionFilters: options.exclusionFilters, + }); + return new AdbDaemonWebUsbDevice(device, filters, this.#usbManager); } catch (e) { // No device selected if (isErrorName(e, "NotFoundError")) { @@ -85,34 +77,35 @@ export class AdbDaemonWebUsbDeviceManager { * It must have `classCode`, `subclassCode` and `protocolCode` fields for selecting the ADB interface, * but might also have `vendorId`, `productId` or `serialNumber` fields to limit the device list. * - * Defaults to {@link ADB_DEFAULT_DEVICE_FILTER}. + * Defaults to {@link ADB_DEFAULT_INTERFACE_FILTER}. * @returns An array of {@link AdbDaemonWebUsbDevice} instances for all connected and authenticated devices. */ + getDevices( + filters?: USBDeviceFilter[] | undefined, + ): Promise; async getDevices( - filters: AdbDeviceFilter[] = [ADB_DEFAULT_DEVICE_FILTER], + filters_: USBDeviceFilter[] | undefined, ): Promise { - if (filters.length === 0) { - throw new TypeError("filters must not be empty"); - } + const filters = toAdbDeviceFilters(filters_); const devices = await this.#usbManager.getDevices(); return devices .filter((device) => { for (const filter of filters) { if ( - "vendorId" in filter && + filter.vendorId !== undefined && device.vendorId !== filter.vendorId ) { continue; } if ( - "productId" in filter && + filter.productId !== undefined && device.productId !== filter.productId ) { continue; } if ( - "serialNumber" in filter && + filter.serialNumber !== undefined && getSerialNumber(device) !== filter.serialNumber ) { continue; diff --git a/libraries/adb-daemon-webusb/src/utils.ts b/libraries/adb-daemon-webusb/src/utils.ts index a7baad14..81c177d6 100644 --- a/libraries/adb-daemon-webusb/src/utils.ts +++ b/libraries/adb-daemon-webusb/src/utils.ts @@ -7,18 +7,22 @@ export function isErrorName(e: unknown, name: string): boolean { ); } +export type PickNonNullable = { + [P in K]-?: NonNullable; +}; + /** * `classCode`, `subclassCode` and `protocolCode` are required * for selecting correct USB configuration and interface. */ -export type AdbDeviceFilter = USBDeviceFilter & - Required< - Pick - >; +export type UsbInterfaceFilter = PickNonNullable< + USBDeviceFilter, + "classCode" | "subclassCode" | "protocolCode" +>; function alternateMatchesFilter( alternate: USBAlternateInterface, - filters: AdbDeviceFilter[], + filters: UsbInterfaceFilter[], ) { return filters.some( (filter) => @@ -30,7 +34,7 @@ function alternateMatchesFilter( export function findUsbAlternateInterface( device: USBDevice, - filters: AdbDeviceFilter[], + filters: UsbInterfaceFilter[], ) { for (const configuration of device.configurations) { for (const interface_ of configuration.interfaces) { @@ -56,3 +60,42 @@ export function getSerialNumber(device: USBDevice) { return padNumber(device.vendorId) + "x" + padNumber(device.productId); } + +/** + * Find the first pair of input and output endpoints from an alternate interface. + * + * ADB interface only has two endpoints, one for input and one for output. + */ +export function findUsbEndpoints(endpoints: USBEndpoint[]) { + if (endpoints.length === 0) { + throw new TypeError("No endpoints given"); + } + + let inEndpoint: USBEndpoint | undefined; + let outEndpoint: USBEndpoint | undefined; + + for (const endpoint of endpoints) { + switch (endpoint.direction) { + case "in": + inEndpoint = endpoint; + if (outEndpoint) { + return { inEndpoint, outEndpoint }; + } + break; + case "out": + outEndpoint = endpoint; + if (inEndpoint) { + return { inEndpoint, outEndpoint }; + } + break; + } + } + + if (!inEndpoint) { + throw new TypeError("No input endpoint found."); + } + if (!outEndpoint) { + throw new TypeError("No output endpoint found."); + } + throw new Error("unreachable"); +} diff --git a/libraries/adb-daemon-webusb/tsconfig.test.json b/libraries/adb-daemon-webusb/tsconfig.test.json new file mode 100644 index 00000000..46c93d25 --- /dev/null +++ b/libraries/adb-daemon-webusb/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "types": [ + "w3c-web-usb", + "node" + ] + }, + "exclude": [] +} diff --git a/libraries/adb/README.md b/libraries/adb/README.md index d5150393..ad74b2b8 100644 --- a/libraries/adb/README.md +++ b/libraries/adb/README.md @@ -15,6 +15,9 @@ GitHub release + + Package Size + npm diff --git a/libraries/adb/package.json b/libraries/adb/package.json index baa9f4b8..bfcc6904 100644 --- a/libraries/adb/package.json +++ b/libraries/adb/package.json @@ -27,9 +27,9 @@ "scripts": { "build": "tsc -b tsconfig.build.json", "build:watch": "tsc -b tsconfig.build.json", - "test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" TS_JEST_DISABLE_VER_CHECKER=true jest --coverage", "lint": "run-eslint && prettier src/**/*.ts --write --tab-width 4", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" TS_JEST_DISABLE_VER_CHECKER=true jest --coverage" }, "dependencies": { "@yume-chan/async": "^2.2.0", diff --git a/libraries/adb/tsconfig.build.json b/libraries/adb/tsconfig.build.json index 703b7998..335d0338 100644 --- a/libraries/adb/tsconfig.build.json +++ b/libraries/adb/tsconfig.build.json @@ -4,6 +4,9 @@ { "path": "../event/tsconfig.build.json" }, + { + "path": "../no-data-view/tsconfig.build.json" + }, { "path": "../stream-extra/tsconfig.build.json" }, diff --git a/libraries/adb/tsconfig.json b/libraries/adb/tsconfig.json index 04f9aba7..85fc5a7a 100644 --- a/libraries/adb/tsconfig.json +++ b/libraries/adb/tsconfig.json @@ -1,8 +1,5 @@ { "references": [ - { - "path": "../no-data-view/tsconfig.build.json" - }, { "path": "./tsconfig.test.json" },