diff --git a/.github/workflows/test-typescript-npm.yml b/.github/workflows/test-typescript-npm.yml new file mode 100644 index 0000000..e7eb503 --- /dev/null +++ b/.github/workflows/test-typescript-npm.yml @@ -0,0 +1,73 @@ +name: Test TypeScript + +env: + # See: https://github.com/actions/setup-node/#readme + NODE_VERSION: 10.x + +on: + push: + paths: + - ".github/workflows/test-typescript-npm.ya?ml" + - ".github/.?codecov.ya?ml" + - "dev/.?codecov.ya?ml" + - ".?codecov.ya?ml" + - "jest.config.js" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "**.js" + - "**.jsx" + - "**.ts" + - "**.tsx" + pull_request: + paths: + - ".github/workflows/test-typescript-npm.ya?ml" + - "jest.config.js" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "**.js" + - "**.jsx" + - "**.ts" + - "**.tsx" + schedule: + # Run periodically to catch breakage caused by external changes. + - cron: "0 13 * * WED" + workflow_dispatch: + repository_dispatch: + +jobs: + test: + runs-on: ${{ matrix.operating-system }} + + strategy: + fail-fast: false + + matrix: + operating-system: + - macos-latest + - ubuntu-latest + # The version of node-gyp used by this project (7.1.2) requires an older version of Visual Studio that is not + # available in the latest Windows GitHub Actions runner. + - windows-2019 + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm run-script test + + - name: Send unit test coverage to Codecov + if: runner.os == 'Linux' + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: ${{ github.repository == 'arduino/arduino-serial-plotter-webapp' }} diff --git a/README.md b/README.md index 14813e0..cb9d602 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Serial Plotter WebApp +[![Test TypeScript status](https://github.com/arduino/arduino-serial-plotter-webapp/actions/workflows/test-typescript-npm.yml/badge.svg)](https://github.com/arduino/arduino-serial-plotter-webapp/actions/workflows/test-typescript-npm.yml) + This is a SPA that receives data points over WebSocket and prints graphs. The purpose is to provide a visual and live representation of data printed to the Serial Port. The application is designed to be as agnostic as possible regarding how and where it runs. For this reason, it accepts different settings when it's launched in order to configure the look&feel and the connection parameters. @@ -162,6 +164,7 @@ These are sent to the middleware to be stored and propagated to other clients. ## Development - `npm i` to install dependencies +- `npm test` to run automated tests - `npm start` to run the application in development mode @ [http://localhost:3000](http://localhost:3000) ## Deployment diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..d0d02df --- /dev/null +++ b/jest.config.js @@ -0,0 +1,12 @@ +/* + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/en/configuration.html + */ + +module.exports = { + collectCoverage: true, + coverageDirectory: "coverage", + coverageReporters: ["lcov"], + preset: "ts-jest", + testEnvironment: "node", +}; diff --git a/package-lock.json b/package-lock.json index 6ea40da..fe4fcb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5818,6 +5818,15 @@ "picocolors": "^0.2.1" } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -10305,9 +10314,9 @@ } }, "import-local": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.0.3.tgz", - "integrity": "sha512-bE9iaUY3CXH8Cwfan/abDKAxe1KGT9kyGsBPqf6DMK/z0a2OzAsrukeYNgIH6cH5Xr452jb1TUL8rSfCLjZ9uA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", "dev": true, "requires": { "pkg-dir": "^4.2.0", @@ -10897,9 +10906,9 @@ } }, "istanbul-lib-source-maps": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz", - "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, "requires": { "debug": "^4.1.1", @@ -10916,9 +10925,9 @@ } }, "istanbul-reports": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.3.tgz", - "integrity": "sha512-0i77ZFLsb9U3DHi22WzmIngVzfoyxxbQcZRqlF3KoKmCJGq9nhFHoGi8FqBztN2rE8w6hURnZghetn0xpkVb6A==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", + "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", "dev": true, "requires": { "html-escaper": "^2.0.0", @@ -13015,6 +13024,12 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "makeerror": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", @@ -18615,9 +18630,9 @@ } }, "supports-hyperlinks": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz", - "integrity": "sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", "dev": true, "requires": { "has-flag": "^4.0.0", @@ -19105,6 +19120,38 @@ "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", "dev": true }, + "ts-jest": { + "version": "26.5.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.6.tgz", + "integrity": "sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "buffer-from": "1.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^26.1.0", + "json5": "2.x", + "lodash": "4.x", + "make-error": "1.x", + "mkdirp": "1.x", + "semver": "7.x", + "yargs-parser": "20.x" + }, + "dependencies": { + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true + } + } + }, "ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", @@ -19539,9 +19586,9 @@ }, "dependencies": { "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true } } diff --git a/package.json b/package.json index e00f1ce..5466acc 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "react-scripts start", "build": "react-scripts build", - "test": "react-scripts test", + "test": "jest", "eject": "react-scripts eject" }, "files": [ @@ -49,6 +49,7 @@ "chartjs-plugin-streaming": "^2.0.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", + "jest": "^26.6.0", "luxon": "^2.1.0", "node-sass": "^6.0.1", "prettier": "^2.4.1", @@ -60,6 +61,7 @@ "react-scripts": "4.0.3", "react-select": "^5.1.0", "react-switch": "^6.0.0", + "ts-jest": "^26.5.6", "typescript": "^4.4.3", "web-vitals": "^1.1.2", "worker-loader": "^3.0.8" diff --git a/src/msgAggregatorWorker.test.ts b/src/msgAggregatorWorker.test.ts new file mode 100644 index 0000000..6d14944 --- /dev/null +++ b/src/msgAggregatorWorker.test.ts @@ -0,0 +1,141 @@ +/* + Copyright (C) 2022 Arduino SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +class WorkerStub { + listener = (_: { data: { command: string } }) => {}; + addEventListener(_: string, listenerCallback: (event: object) => void) { + this.listener = listenerCallback; + } +} +let worker = new WorkerStub(); +(global as any).self = worker; + +const messageAggregator = require("./msgAggregatorWorker"); + +beforeEach(() => { + worker.listener({ data: { command: "cleanup" } }); +}); + +describe("Parsing data", () => { + describe.each([ + ["space", " "], + ["tab", "\t"], + ["comma", ","], + ])("%s field delimiter", (_, fieldDelimiter) => { + describe.each([ + ["trailing", fieldDelimiter], + ["no trailing", ""], + ])("%s", (_, trailingFieldDelimiter) => { + describe.each([ + ["LF", "\n"], + ["CRLF", "\r\n"], + ])("%s record delimiter", (_, recordDelimiter) => { + test("single field", () => { + const messages = [ + `0${trailingFieldDelimiter}${recordDelimiter}`, + `1${trailingFieldDelimiter}${recordDelimiter}`, + `2${trailingFieldDelimiter}${recordDelimiter}`, + ]; + + const assertion = { + datasetNames: ["value 1"], + parsedLines: [{ "value 1": 1 }, { "value 1": 2 }], + }; + + expect(messageAggregator.parseSerialMessages(messages)).toEqual( + assertion + ); + }); + + test("multi-field", () => { + const messages = [ + `0${trailingFieldDelimiter}${recordDelimiter}`, + `1${fieldDelimiter}2${trailingFieldDelimiter}${recordDelimiter}`, + `3${fieldDelimiter}4${trailingFieldDelimiter}${recordDelimiter}`, + ]; + + const assertion = { + datasetNames: ["value 1", "value 2"], + parsedLines: [ + { "value 1": 1, "value 2": 2 }, + { "value 1": 3, "value 2": 4 }, + ], + }; + + expect(messageAggregator.parseSerialMessages(messages)).toEqual( + assertion + ); + }); + + test("labeled", () => { + const messages = [ + `0${trailingFieldDelimiter}${recordDelimiter}`, + `label_1:1${fieldDelimiter}label_2:2${trailingFieldDelimiter}${recordDelimiter}`, + `label_1:3${fieldDelimiter}label_2:4${trailingFieldDelimiter}${recordDelimiter}`, + ]; + + const assertion = { + datasetNames: ["label_1", "label_2"], + parsedLines: [ + { label_1: 1, label_2: 2 }, + { label_1: 3, label_2: 4 }, + ], + }; + + expect(messageAggregator.parseSerialMessages(messages)).toEqual( + assertion + ); + }); + + test("buffering", () => { + // Incomplete record + let messages = [ + `0${trailingFieldDelimiter}${recordDelimiter}`, + `1${fieldDelimiter}`, + ]; + + // Incomplete message is buffered + let assertion: { + datasetNames: string[]; + parsedLines: { [key: string]: number }[]; + } = { + datasetNames: [], + parsedLines: [], + }; + + expect(messageAggregator.parseSerialMessages(messages)).toEqual( + assertion + ); + + // Second part of the record + messages = [`2${trailingFieldDelimiter}${recordDelimiter}`]; + + assertion = { + datasetNames: ["value 1", "value 2"], + parsedLines: [{ "value 1": 1, "value 2": 2 }], + }; + + expect(messageAggregator.parseSerialMessages(messages)).toEqual( + assertion + ); + }); + }); + }); + }); +}); + +export {}; diff --git a/src/msgAggregatorWorker.ts b/src/msgAggregatorWorker.ts index f220a5a..9b96757 100644 --- a/src/msgAggregatorWorker.ts +++ b/src/msgAggregatorWorker.ts @@ -18,8 +18,10 @@ ctx.addEventListener("message", (event) => { let buffer = ""; let discardFirstLine = true; -const separator = "\n"; -var re = new RegExp(`(${separator})`, "g"); +const separator = "\r?\n"; +const delimiter = "[, \t]+"; // Serial Plotter protocol supports Comma, Space & Tab characters as delimiters +var separatorRegex = new RegExp(`(${separator})`, "g"); +var delimiterRegex = new RegExp(delimiter, "g"); export const parseSerialMessages = ( messages: string[] @@ -31,10 +33,11 @@ export const parseSerialMessages = ( // so we need to discard it and start aggregating from the first encountered separator let joinMessages = messages.join(""); if (discardFirstLine) { - const firstSeparatorIndex = joinMessages.indexOf(separator); - if (firstSeparatorIndex > -1) { + separatorRegex.lastIndex = 0; // Reset lastIndex to ensure match happens from beginning of string + const separatorMatch = separatorRegex.exec(joinMessages); + if (separatorMatch && separatorMatch.index > -1) { joinMessages = joinMessages.substring( - firstSeparatorIndex + separator.length + separatorMatch.index + separatorMatch[0].length ); discardFirstLine = false; } else { @@ -47,13 +50,14 @@ export const parseSerialMessages = ( //add any leftover from the buffer to the first line const messagesAndBuffer = ((buffer || "") + joinMessages) - .split(re) + .split(separatorRegex) .filter((message) => message.length > 0); // remove the previous buffer buffer = ""; + separatorRegex.lastIndex = 0; // check if the last message contains the delimiter, if not, it's an incomplete string that needs to be added to the buffer - if (messagesAndBuffer[messagesAndBuffer.length - 1] !== separator) { + if (!separatorRegex.test(messagesAndBuffer[messagesAndBuffer.length - 1])) { buffer = messagesAndBuffer[messagesAndBuffer.length - 1]; messagesAndBuffer.splice(-1); } @@ -62,19 +66,21 @@ export const parseSerialMessages = ( const parsedLines: { [key: string]: number }[] = []; // for each line, explode variables + separatorRegex.lastIndex = 0; messagesAndBuffer - .filter((message) => message !== separator) + .filter((message) => !separatorRegex.test(message)) .forEach((message) => { const parsedLine: { [key: string]: number } = {}; - //there are two supported formats: - // format1: - // format2: name1:,name2:,name3: + // Part Separator symbols i.e. Space, Tab & Comma are fully supported + // SerialPlotter protocol specifies 3 message formats. The following 2 formats are supported + // Value only format: + // Label-Value format: name1:,name2:,name3: // if we find a colon, we assume the latter is being used let tokens: string[] = []; if (message.indexOf(":") > 0) { - message.split(",").forEach((keyValue: string) => { + message.split(delimiterRegex).forEach((keyValue: string) => { let [key, value] = keyValue.split(":"); key = key && key.trim(); value = value && value.trim(); @@ -83,8 +89,8 @@ export const parseSerialMessages = ( } }); } else { - // otherwise they are spaces - const values = message.split(/\s/); + // otherwise they are unlabelled + const values = message.split(delimiterRegex); values.forEach((value, i) => { if (value.length) { tokens.push(...[`value ${i + 1}`, value]);