From 3102bb4af0aa9f147bf415e3d86943c7ea73f66e Mon Sep 17 00:00:00 2001 From: Roberto Mosca Date: Tue, 12 Dec 2023 00:23:09 +0100 Subject: [PATCH] First tests for device.ts --- .vscode/launch.json | 12 ---- package.json | 4 ++ pnpm-lock.yaml | 119 +++++++++++++++++++++++++++++++++++++++ src/device.ts | 83 ++++----------------------- src/test/device.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 84 deletions(-) create mode 100644 src/test/device.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 0855344..ecbf1f6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,18 +15,6 @@ ], "outFiles": ["${workspaceFolder}/out/**/*.js"], "preLaunchTask": "${defaultBuildTask}" - }, - { - "name": "Extension Tests", - "type": "extensionHost", - "request": "launch", - "args": [ - "--disable-extensions", - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" - ], - "outFiles": ["${workspaceFolder}/out/test/**/*.js"], - "preLaunchTask": "${defaultBuildTask}" } ] } diff --git a/package.json b/package.json index cba14b9..d69a43c 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,8 @@ "@types/mocha": "^10.0.6", "@types/node": "^18.19.3", "@types/serialport": "^8.0.1", + "@types/sinon": "^17.0.2", + "@types/sinon-chai": "^3.2.12", "@types/vscode": "^1.53.0", "@typescript-eslint/eslint-plugin": "^6.13.2", "@typescript-eslint/parser": "^6.13.2", @@ -125,6 +127,8 @@ "glob": "^10.3.10", "mocha": "^10.2.0", "prettier": "^3.1.0", + "sinon": "^17.0.1", + "sinon-chai": "^3.7.0", "typescript": "^5.3.3" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48af1e1..2eb7811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,12 @@ devDependencies: '@types/serialport': specifier: ^8.0.1 version: 8.0.5 + '@types/sinon': + specifier: ^17.0.2 + version: 17.0.2 + '@types/sinon-chai': + specifier: ^3.2.12 + version: 3.2.12 '@types/vscode': specifier: ^1.53.0 version: 1.85.0 @@ -58,6 +64,12 @@ devDependencies: prettier: specifier: ^3.1.0 version: 3.1.0 + sinon: + specifier: ^17.0.1 + version: 17.0.1 + sinon-chai: + specifier: ^3.7.0 + version: 3.7.0(chai@4.3.10)(sinon@17.0.1) typescript: specifier: ^5.3.3 version: 5.3.3 @@ -524,6 +536,42 @@ packages: - supports-color dev: false + /@sinonjs/commons@2.0.0: + resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/commons@3.0.0: + resolution: {integrity: sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/fake-timers@10.3.0: + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + dependencies: + '@sinonjs/commons': 3.0.0 + dev: true + + /@sinonjs/fake-timers@11.2.2: + resolution: {integrity: sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==} + dependencies: + '@sinonjs/commons': 3.0.0 + dev: true + + /@sinonjs/samsam@8.0.0: + resolution: {integrity: sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==} + dependencies: + '@sinonjs/commons': 2.0.0 + lodash.get: 4.4.2 + type-detect: 4.0.8 + dev: true + + /@sinonjs/text-encoding@0.7.2: + resolution: {integrity: sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==} + dev: true + /@tootallnate/once@1.1.2: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -568,6 +616,23 @@ packages: '@types/node': 18.19.3 dev: true + /@types/sinon-chai@3.2.12: + resolution: {integrity: sha512-9y0Gflk3b0+NhQZ/oxGtaAJDvRywCa5sIyaVnounqLvmf93yBF4EgIRspePtkMs3Tr844nCclYMlcCNmLCvjuQ==} + dependencies: + '@types/chai': 4.3.11 + '@types/sinon': 17.0.2 + dev: true + + /@types/sinon@17.0.2: + resolution: {integrity: sha512-Zt6heIGsdqERkxctIpvN5Pv3edgBrhoeb3yHyxffd4InN0AX2SVNKSrhdDZKGQICVOxWP/q4DyhpfPNMSrpIiA==} + dependencies: + '@types/sinonjs__fake-timers': 8.1.5 + dev: true + + /@types/sinonjs__fake-timers@8.1.5: + resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==} + dev: true + /@types/vscode@1.85.0: resolution: {integrity: sha512-CF/RBon/GXwdfmnjZj0WTUMZN5H6YITOfBCP4iEZlOtVQXuzw6t7Le7+cR+7JzdMrnlm7Mfp49Oj2TuSXIWo3g==} dev: true @@ -1093,6 +1158,11 @@ packages: engines: {node: '>=0.3.1'} dev: true + /diff@5.1.0: + resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==} + engines: {node: '>=0.3.1'} + dev: true + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1601,6 +1671,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + dev: true + /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} dev: true @@ -1734,6 +1808,10 @@ packages: setimmediate: 1.0.5 dev: true + /just-extend@4.2.1: + resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==} + dev: true + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -1772,6 +1850,10 @@ packages: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} dev: true + /lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + dev: true + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true @@ -1905,6 +1987,16 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true + /nise@5.1.5: + resolution: {integrity: sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==} + dependencies: + '@sinonjs/commons': 2.0.0 + '@sinonjs/fake-timers': 10.3.0 + '@sinonjs/text-encoding': 0.7.2 + just-extend: 4.2.1 + path-to-regexp: 1.8.0 + dev: true + /node-addon-api@7.0.0: resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} dev: false @@ -2068,6 +2160,12 @@ packages: minipass: 7.0.4 dev: true + /path-to-regexp@1.8.0: + resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} + dependencies: + isarray: 0.0.1 + dev: true + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2271,6 +2369,27 @@ packages: engines: {node: '>=14'} dev: true + /sinon-chai@3.7.0(chai@4.3.10)(sinon@17.0.1): + resolution: {integrity: sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==} + peerDependencies: + chai: ^4.0.0 + sinon: '>=4.0.0' + dependencies: + chai: 4.3.10 + sinon: 17.0.1 + dev: true + + /sinon@17.0.1: + resolution: {integrity: sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==} + dependencies: + '@sinonjs/commons': 3.0.0 + '@sinonjs/fake-timers': 11.2.2 + '@sinonjs/samsam': 8.0.0 + diff: 5.1.0 + nise: 5.1.5 + supports-color: 7.2.0 + dev: true + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} diff --git a/src/device.ts b/src/device.ts index 226f22c..5876864 100644 --- a/src/device.ts +++ b/src/device.ts @@ -1,11 +1,15 @@ -import { SerialPort } from 'serialport'; +import { SerialPort, SerialPortMock } from 'serialport'; import { EventEmitter } from 'events'; import { APIRequest } from './api'; import { encodeBase64 } from './utils'; -export type SlotType = 'all' | 'empty' | 'full'; +const _testing: { + SerialPortType: typeof SerialPort | typeof SerialPortMock; +} = { + SerialPortType: SerialPort, +}; -const PROMPT = '\r\n>>> '; +export type SlotType = 'all' | 'empty' | 'full'; export enum DeviceMode { REPL, @@ -49,7 +53,7 @@ class Device extends EventEmitter { ttyDevice: string; name: string; firmwareVersion: string; - serialPort: SerialPort | undefined; + serialPort: SerialPort | SerialPortMock | undefined; devMode: DeviceMode; storageStatus: StorageStatus | undefined; @@ -69,73 +73,6 @@ class Device extends EventEmitter { } } - private async obtainPrompt() { - return new Promise((resolve) => { - this.assertConnected(); - const dataHandler = (data: Buffer) => { - if (data.toString().endsWith(PROMPT)) { - this.serialPort?.removeListener('data', dataHandler); - this.devMode = DeviceMode.REPL; - resolve(); - } - }; - this.serialPort?.on('data', dataHandler); - this.serialPort?.write('\x03'); - }); - } - - private async execPythonCmd(cmd: string): Promise { - const removePrefix = (str: string, prefix: string) => - str.startsWith(prefix) ? str.substring(prefix.length) : str; - const removeSuffix = (str: string, prefix: string) => - str.endsWith(prefix) ? str.substring(0, str.length - prefix.length) : str; - - if (this.devMode !== DeviceMode.REPL) { - await this.obtainPrompt(); - } - - return new Promise((resolve, reject) => { - this.assertConnected(); - - let output = ''; - - const processData = (data: Buffer) => { - const stringData = data.toString(); - if (stringData.endsWith(PROMPT)) { - this.serialPort?.removeListener('data', processData); - output += removeSuffix(stringData, PROMPT); - output = removePrefix(output, cmd); - output = removePrefix(output, '... \r\n'); - if (output.toLowerCase().trim().startsWith('traceback')) { - reject(new Error(output)); - } else { - resolve(output); - } - } else { - output += stringData; - } - }; - - this.serialPort?.on('data', processData); - this.serialPort?.write(Buffer.from(`${cmd}\r\n`)); - this.serialPort?.flush(); - }); - } - - // TODO: Disabling retrieval of name for the moment as this would require - // a soft restart to go back to API mode - // async retrieveName() { - // try { - // const response = await this.execPythonCmd( - // "with open('local_name.txt') as f: print(f.read())\r\n", - // ); - // this.name = response; - // } catch (err) { - // this.name = 'LEGO Hub'; - // } - // this.emit('change'); - // } - private executeSlotSpecificCommand(cmd: string, slotId: number) { this.assertConnected(); checkSlotId(slotId); @@ -231,7 +168,7 @@ class Device extends EventEmitter { connect() { return new Promise((resolve, reject) => { - this.serialPort = new SerialPort( + this.serialPort = new _testing.SerialPortType( { path: this.ttyDevice, baudRate: 115200, @@ -342,3 +279,5 @@ export function addDeviceOnChangeCallbak(callback: () => void) { export function removeDeviceAllListeners() { device?.removeAllListeners(); } + +export { _testing }; diff --git a/src/test/device.test.ts b/src/test/device.test.ts new file mode 100644 index 0000000..d762d3b --- /dev/null +++ b/src/test/device.test.ts @@ -0,0 +1,120 @@ +import { + connectDevice, + _testing, + isDeviceConnected, + disconnectDevice, +} from '../device'; +import { SerialPortMock } from 'serialport'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import * as api from '../api'; + +chai.use(sinonChai); +const expect = chai.expect; + +describe('Device', () => { + const deviceName = '/dev/mock'; + const APIRequestStub = sinon.stub(api, 'APIRequest'); + + beforeEach(() => { + _testing.SerialPortType = SerialPortMock; + }); + + afterEach(() => { + SerialPortMock.binding.reset(); + APIRequestStub.restore(); + disconnectDevice(); + }); + + const createPortMock = (data: string, opts?: any) => { + return SerialPortMock.binding.createPort(deviceName, { + echo: true, + record: true, + ...opts, + readyData: Buffer.from(data), + }); + }; + + describe('connectDevice', function () { + it('should connect to the device and refresh storage status', async function () { + // Arrange + createPortMock(''); + APIRequestStub.resolves({ + storage: { total: 100, used: 50, available: 50 }, + slots: { + 1: { + name: 'Program 1', + id: 1, + project_id: 'asi3', + modified: new Date(), + type: 'python', + created: new Date(), + size: 123, + }, + 2: { + name: 'Program 2', + id: 2, + project_id: 'erhw', + modified: new Date(), + type: 'python', + created: new Date(), + size: 321, + }, + }, + }); + + await connectDevice(deviceName); + + expect(isDeviceConnected()).to.be.true; + + expect(APIRequestStub).to.have.been.calledOnce; + expect(APIRequestStub).to.have.been.calledWith( + sinon.match.any, + 'get_storage_status', + {}, + ); + }); + + it('should throw an error if the device is already connected', async function () { + // Arrange + createPortMock(''); + + await connectDevice(deviceName); + + let error: Error | undefined = undefined; + try { + await connectDevice(deviceName); + } catch (err) { + expect(err).instanceOf(Error); + error = err as Error; + } + expect(error?.message).to.be.equal( + 'Device already connected. First disconnect it...', + ); + }); + + it('should throw an error if device connection fails', async function () { + createPortMock(''); + + let error: Error | undefined = undefined; + try { + await connectDevice('/dev/anotherDevice'); + } catch (err) { + expect(err).instanceOf(Error); + error = err as Error; + } + expect(error?.message).to.match(/^Port does not exist/); + }); + + // it('should throw an error if refreshing storage status fails', async function () { + // // Arrange + // const deviceName = 'MyDevice'; + + // // Act and Assert + // await expect(connectDevice(deviceName)).to.be.rejectedWith( + // 'Failed to refresh storage status', + // ); + // }); + }); +});