diff --git a/.gitignore b/.gitignore index 0b60dfa..cca6f52 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist node_modules .vscode-test/ *.vsix +src/test/test-workspace/* +!src/test/test-workspace/.gitkeep diff --git a/.vscode/launch.json b/.vscode/launch.json index 91e4189..26f2db4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,9 +14,13 @@ "type": "extensionHost", "request": "launch", "args": [ + "${workspaceFolder}/src/test/test-workspace", "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" ], + "env": { + "NO_TEST_TIMEOUT": "true" + }, "outFiles": [ "${workspaceFolder}/out/**/*.js", "${workspaceFolder}/dist/**/*.js" diff --git a/.vscodeignore b/.vscodeignore index c613679..c8c848f 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,10 +1,12 @@ +.github/** .vscode/** .vscode-test/** out/** node_modules/** src/** .gitignore -.yarnrc +.prettierignore +CHANGELOG.md webpack.config.js vsc-extension-quickstart.md **/tsconfig.json diff --git a/README.md b/README.md index 2b17fdc..718d699 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ # vscode-arduino-api -Arduino API for [Arduino IDE](https://github.com/arduino/arduino-ide) external tools developers using VS Code extensions. +Arduino API for [Arduino IDE 2.x](https://github.com/arduino/arduino-ide) external tools developers using VS Code extensions. -This VS Code extensions does not provide any functionality, but a bridge between the Arduino IDE and external tools implemented as a VS Code extension. Please reference [arduino/arduino-ide#58](https://github.com/arduino/arduino-ide/issues/58) why this VSIX has been created. This extension has nothing to do with the [Visual Studio Code extension for Arduino](https://marketplace.visualstudio.com/items?itemName=vsciot-vscode.vscode-arduino). This extension does not work in VS Code. +This VS Code extension does not provide any functionality but a bridge between the Arduino IDE 2.x and external tools implemented as a VS Code extension. Please reference [arduino/arduino-ide#58](https://github.com/arduino/arduino-ide/issues/58) to explain why this VSIX has been created. This extension has nothing to do with the [Visual Studio Code extension for Arduino](https://marketplace.visualstudio.com/items?itemName=vsciot-vscode.vscode-arduino). This extension does not work in VS Code. -## Features +## API Exposes the Arduino context for VS Code extensions: -| Name | Description | Type | Note | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------- | -| `sketchPath` | Absolute filesystem path of the sketch folder. | `string` | -| `buildPath` | The absolute filesystem path to the build folder of the sketch. When the `sketchPath` is available but the sketch has not been verified (compiled), the `buildPath` can be `undefined`. | `string` | ⚠️ `@alpha` | -| `fqbn` | The Fully Qualified Board Name (FQBN) of the currently selected board in the Arduino IDE. | `string` | -| `boardDetails` | Lightweight representation of the board's detail. This information is [provided by the Arduino CLI](https://arduino.github.io/arduino-cli/latest/rpc/commands/#cc.arduino.cli.commands.v1.BoardDetailsResponse) for the currently selected board. It can be `undefined` if the `fqbn` is defined but the platform is not installed. | `BoardDetails` | ⚠️ `@alpha` | -| `port` | The currently selected port in the Arduino IDE. | [`Port`](https://arduino.github.io/arduino-cli/latest/rpc/commands/#port) | -| `userDirPath` | Filesystem path to the [`directories.user`](https://arduino.github.io/arduino-cli/latest/configuration/#configuration-keys) location. This is the sketchbook path. | `string` | -| `dataDirPath` | Filesystem path to the [`directories.data`](https://arduino.github.io/arduino-cli/latest/configuration/#configuration-keys) location | `string` | +| Name | Description | Type | Note | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | ----------- | +| `sketchPath` | Absolute filesystem path of the sketch folder. | `string` | +| `compileSummary` | The summary of the latest sketch compilation. When the `sketchPath` is available, but the sketch has not been verified (compiled), the `buildPath` can be `undefined`. | `CompileSummary` | ⚠️ `@alpha` | +| `fqbn` | The Fully Qualified Board Name (FQBN) of the currently selected board in the Arduino IDE. | `string` | +| `boardDetails` | Lightweight representation of the board's detail. This information is [provided by the Arduino CLI](https://arduino.github.io/arduino-cli/latest/rpc/commands/#cc.arduino.cli.commands.v1.BoardDetailsResponse) for the currently selected board. It can be `undefined` if the `fqbn` is defined, but the platform is not installed. | `BoardDetails` | ⚠️ `@alpha` | +| `port` | The currently selected port in the Arduino IDE. | [`Port`](https://arduino.github.io/arduino-cli/latest/rpc/commands/#port) | +| `userDirPath` | Filesystem path to the [`directories.user`](https://arduino.github.io/arduino-cli/latest/configuration/#configuration-keys) location. This is the sketchbook path. | `string` | ⚠️ `@alpha` | +| `dataDirPath` | Filesystem path to the [`directories.data`](https://arduino.github.io/arduino-cli/latest/configuration/#configuration-keys) location | `string` | ⚠️ `@alpha` | ## How to Use @@ -25,34 +25,45 @@ If you're developing an external tool for the Arduino IDE, this extension will b If you want to use the Arduino APIs, you have to do the followings: 1. Install the [Arduino API types](https://www.npmjs.com/package/vscode-arduino-api) from `npm`: + ```shell - npm i -S vscode-arduino-api - ``` -1. Add this VSIX as an `extensionDependencies` in your `package.json`: - - ```jsonc - { - "extensionDependencies": [ - "dankeboy36.vscode-arduino-api", - // other dependencies - ], - } + npm install vscode-arduino-api --save ``` + 1. Consume the `ArduinoContext` extension API in your VS Code extension: ```ts import * as vscode from 'vscode'; import type { ArduinoContext } from 'vscode-arduino-api'; - function activate(context: vscode.ExtensionContext) { + export function activate(context: vscode.ExtensionContext) { const arduinoContext: ArduinoContext = vscode.extensions.getExtension( 'dankeboy36.vscode-arduino-api' )?.exports; if (!arduinoContext) { - // failed to load the Arduino API + // Failed to load the Arduino API. return; } - // use the Arduino API in your VS Code extension... + + // Use the Arduino API in your VS Code extension. + + // Read the state. + // Register a command to access the sketch path and show it as an information message. + context.subscriptions.push( + vscode.commands.registerCommand('myExtension.showSketchPath', () => { + vscode.window.showInformationMessage( + `Sketch path: ${arduinoContext.sketchPath}` + ); + }) + ); + + // Listen on state change. + // Register a listener to show the FQBN of the currently selected board as an information message. + context.subscriptions.push( + arduinoContext.onDidChange('fqbn')((fqbn) => + vscode.window.showInformationMessage(`FQBN: ${fqbn}`) + ) + ); } ``` @@ -60,10 +71,20 @@ If you want to use the Arduino APIs, you have to do the followings: --- +- Q: What does `@alpha` mean? +- A: This API is in an alpha state and might change. The initial idea of this project was to establish a bare minimum layer and help Arduino IDE 2.x tool developers start with something. I make breaking changes only when necessary, keep it backward compatible, or provide a migration guide in the future. Please prepare for breaking changes. + +--- + - Q: Why do I have to install `vscode-arduino-api` from `npm`. -- A: `vscode-arduino-api` only contains types for the API. The actual code wil be part of the VS Code extension. +- A: `vscode-arduino-api` only contains types for the API. The actual code will be part of the VS Code extension. + +--- + +- Q: I cannot find the `dankeboy36.vscode-arduino-api` extension in neither the [VS Code Marketplace](https://marketplace.visualstudio.com/vscode) nor [Open VSX Registry](https://open-vsx.org/). +- A: Correct. This solution targets the [Arduino IDE](https://github.com/arduino/arduino-ide) 2.x. The IDE will contain this VSIX at runtime and will activate it before your tool VSIX. You do not even have to add `dankeboy36.vscode-arduino-api` to the `extensionDependencies`. I might publish the VSIX later when it works in VS Code. By the way, the VSIX is signed by a verified publisher. You can get the latest version from the GitHub [release page](https://github.com/dankeboy36/vscode-arduino-api/releases/latest). --- -- Q: I cannot find the `dankeboy36.vscode-arduino-api` extension in the [VS Code Marketplace](https://marketplace.visualstudio.com/vscode). -- A: Correct. This solution targets the [Arduino IDE](https://github.com/arduino/arduino-ide). I will publish the VSIX later, when it works in VS Code. By the way, the VSIX is signed with a verified published. +- Q: Are there plans to support it in VS Code? +- A: Sure. diff --git a/package-lock.json b/package-lock.json index d6e9e83..ea5ac57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "vscode-arduino-api", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-arduino-api", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { - "@types/vscode": "^1.78.0" + "@types/vscode": "^1.78.0", + "ardunno-cli": "^0.1.2", + "safe-stable-stringify": "^2.4.3" }, "devDependencies": { "@types/glob": "^8.1.0", @@ -332,6 +334,60 @@ "node": ">=14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -398,8 +454,7 @@ "node_modules/@types/node": { "version": "16.18.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.30.tgz", - "integrity": "sha512-Kmp/wBZk19Dn7uRiol8kF8agnf8m0+TU9qIwyfPmXglVxMlmiIz0VQSMw5oFgwhmD2aKTlfBIO5FtsVj3y7hKQ==", - "dev": true + "integrity": "sha512-Kmp/wBZk19Dn7uRiol8kF8agnf8m0+TU9qIwyfPmXglVxMlmiIz0VQSMw5oFgwhmD2aKTlfBIO5FtsVj3y7hKQ==" }, "node_modules/@types/semver": { "version": "7.5.0", @@ -1089,6 +1144,15 @@ "node": ">= 8" } }, + "node_modules/ardunno-cli": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ardunno-cli/-/ardunno-cli-0.1.2.tgz", + "integrity": "sha512-8PTBMDS2ofe2LJZZKHw/MgfXgDwpiImXJcBeqeZ6lcTSDqQNMJpEIjcCdPcxbsQbJXRRfZZ4nn6G/gXwEuJPpw==", + "dependencies": { + "nice-grpc-common": "^2.0.2", + "protobufjs": "^7.2.3" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2967,6 +3031,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -3285,6 +3354,14 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/nice-grpc-common": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/nice-grpc-common/-/nice-grpc-common-2.0.2.tgz", + "integrity": "sha512-7RNWbls5kAL1QVUOXvBsv1uO0wPQK3lHv+cY1gwkTzirnG1Nop4cBJZubpgziNbaVc/bl9QJcyvsf/NQxa3rjQ==", + "dependencies": { + "ts-error": "^1.0.6" + } + }, "node_modules/node-abi": { "version": "3.40.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.40.0.tgz", @@ -3691,6 +3768,29 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/protobufjs": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.3.tgz", + "integrity": "sha512-TtpvOqwB5Gdz/PQmOjgsrGH1nHjAQVCN7JG4A6r1sXRWESL5rNMAiRcBQlCAdKxZcAbstExQePYG8xof/JVRgg==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -3991,6 +4091,14 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, "node_modules/sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", @@ -4458,6 +4566,11 @@ "node": ">=8.0" } }, + "node_modules/ts-error": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/ts-error/-/ts-error-1.0.6.tgz", + "integrity": "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==" + }, "node_modules/ts-loader": { "version": "9.4.2", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.4.2.tgz", @@ -5157,6 +5270,60 @@ "dev": true, "optional": true }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -5220,8 +5387,7 @@ "@types/node": { "version": "16.18.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.30.tgz", - "integrity": "sha512-Kmp/wBZk19Dn7uRiol8kF8agnf8m0+TU9qIwyfPmXglVxMlmiIz0VQSMw5oFgwhmD2aKTlfBIO5FtsVj3y7hKQ==", - "dev": true + "integrity": "sha512-Kmp/wBZk19Dn7uRiol8kF8agnf8m0+TU9qIwyfPmXglVxMlmiIz0VQSMw5oFgwhmD2aKTlfBIO5FtsVj3y7hKQ==" }, "@types/semver": { "version": "7.5.0", @@ -5726,6 +5892,15 @@ "picomatch": "^2.0.4" } }, + "ardunno-cli": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ardunno-cli/-/ardunno-cli-0.1.2.tgz", + "integrity": "sha512-8PTBMDS2ofe2LJZZKHw/MgfXgDwpiImXJcBeqeZ6lcTSDqQNMJpEIjcCdPcxbsQbJXRRfZZ4nn6G/gXwEuJPpw==", + "requires": { + "nice-grpc-common": "^2.0.2", + "protobufjs": "^7.2.3" + } + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -7094,6 +7269,11 @@ "is-unicode-supported": "^0.1.0" } }, + "long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -7343,6 +7523,14 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "nice-grpc-common": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/nice-grpc-common/-/nice-grpc-common-2.0.2.tgz", + "integrity": "sha512-7RNWbls5kAL1QVUOXvBsv1uO0wPQK3lHv+cY1gwkTzirnG1Nop4cBJZubpgziNbaVc/bl9QJcyvsf/NQxa3rjQ==", + "requires": { + "ts-error": "^1.0.6" + } + }, "node-abi": { "version": "3.40.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.40.0.tgz", @@ -7647,6 +7835,25 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "protobufjs": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.3.tgz", + "integrity": "sha512-TtpvOqwB5Gdz/PQmOjgsrGH1nHjAQVCN7JG4A6r1sXRWESL5rNMAiRcBQlCAdKxZcAbstExQePYG8xof/JVRgg==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + } + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -7855,6 +8062,11 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==" + }, "sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", @@ -8180,6 +8392,11 @@ "is-number": "^7.0.0" } }, + "ts-error": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/ts-error/-/ts-error-1.0.6.tgz", + "integrity": "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==" + }, "ts-loader": { "version": "9.4.2", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.4.2.tgz", diff --git a/package.json b/package.json index 7d6c096..9efe1d6 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "vscode-arduino-api", "displayName": "Arduino API for VS Code extensions", - "description": "Arduino API for Arduino IDE external tools developers using VS Code extensions", - "version": "0.1.0", + "description": "API for Arduino IDE 2.x external tools developers using VS Code extensions", + "version": "0.1.1", "engines": { "vscode": "^1.78.0" }, @@ -33,9 +33,9 @@ "types": "out/api.d.ts", "scripts": { "prepublishOnly": "npm run clean && npm run format && npm run test && npm run lint && npm run package && npm run compile-npm", - "compile": "webpack && vsce package", + "compile": "webpack && vsce package --allow-star-activation", "watch": "webpack --watch", - "package": "webpack --mode production --devtool hidden-source-map && vsce package", + "package": "webpack --mode production --devtool hidden-source-map && vsce package --allow-star-activation", "compile-npm": "tsc -p . --declaration --outDir out", "compile-tests": "tsc -p . --outDir out", "watch-tests": "tsc -p . -w --outDir out", @@ -46,7 +46,9 @@ "clean": "rimraf out dist *.vsix" }, "dependencies": { - "@types/vscode": "^1.78.0" + "@types/vscode": "^1.78.0", + "ardunno-cli": "^0.1.2", + "safe-stable-stringify": "^2.4.3" }, "devDependencies": { "@types/glob": "^8.1.0", @@ -69,22 +71,39 @@ "webpack-cli": "^5.0.2" }, "activationEvents": [ - "onStartupFinished" + "*" ], "contributes": { "commands": [ { - "command": "vscodeArduinoAPI.updateState", + "command": "arduinoAPI.updateState", "title": "Update the Arduino State" } ], "menus": { "commandPalette": [ { - "command": "vscodeArduinoAPI.updateState", + "command": "arduinoAPI.updateState", "when": "false" } ] + }, + "configuration": { + "id": "arduinoAPI", + "title": "Arduino API", + "properties": { + "arduinoAPI.log": { + "type": "boolean", + "default": false, + "markdownDescription": "If `true`, the Arduino state update will be logged to the `Arduino API` _Output Channel_. Defaults to `false`." + }, + "arduinoAPI.compareBeforeUpdate": { + "type": "boolean", + "default": true, + "markdownDescription": "If `true`, the Arduino state update (via the `arduinoAPI.updateState` command) will compare the current state and the new state, and if they are the \"same\", no update will happen. Defaults to `true`.", + "deprecationMessage": "This should be used for development purposes to tweaking the state update behavior while this project is in an early state." + } + } } } } diff --git a/src/api.ts b/src/api.ts index 0b47349..5126018 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,15 @@ -import type { Disposable, Event } from 'vscode'; -export type { Disposable, Event }; +import type { + BoardDetailsResponse, + CompileResponse, + ConfigOption, + ConfigValue, + Port, + Programmer, + ToolsDependencies, +} from 'ardunno-cli'; +import type { Event } from 'vscode'; +export type { Disposable, Event } from 'vscode'; +export type { ConfigOption, ConfigValue, Port, Programmer }; /** * The current state of the Arduino IDE. @@ -11,10 +21,10 @@ export interface ArduinoState { readonly sketchPath: string | undefined; /** - * The absolute filesystem path to the build folder of the sketch. When the `sketchPath` is available but the sketch has not been verified (compiled), the `buildPath` can be `undefined`. + * The summary of the latest sketch compilation. When the `sketchPath` is available but the sketch has not been verified (compiled), the `buildPath` can be `undefined`. * @alpha */ - readonly buildPath: string | undefined; + readonly compileSummary: CompileSummary | undefined; /** * The Fully Qualified Board Name (FQBN) of the currently selected board in the Arduino IDE. @@ -34,11 +44,13 @@ export interface ArduinoState { /** * Filesystem path to the [`directories.user`](https://arduino.github.io/arduino-cli/latest/configuration/#configuration-keys) location. This is the sketchbook path. + * @alpha */ readonly userDirPath: string | undefined; /** * Filesystem path to the [`directories.data`](https://arduino.github.io/arduino-cli/latest/configuration/#configuration-keys) location. + * @alpha */ readonly dataDirPath: string | undefined; } @@ -47,76 +59,55 @@ export interface ArduinoState { * Provides access to the current state of the Arduino IDE such as the sketch path, the currently selected board, and port, and etc. */ export interface ArduinoContext extends ArduinoState { - readonly onDidChange: Readonly< - Record> - >; + onDidChange( + property: T + ): Event; } -// TODO: all types must come from `ardunno-cli`. See https://github.com/dankeboy36/ardunno-cli. /** * Supposed to be a [SemVer](https://semver.org/) as a `string` but it's not enforced by Arduino. You might need to coerce the SemVer string. */ export type Version = string; /** - * Port represents a board port that may be used to upload or to monitor a board. See [`Port`](https://arduino.github.io/arduino-cli/latest/rpc/commands/#cc.arduino.cli.commands.v1.Port) for the CLI API. + * Build properties used for compiling. The board-specific properties are retrieved from `board.txt` and `platform.txt`. For example, if the `board.txt` contains the `build.tarch=xtensa` entry for the `esp32:esp32:esp32` board, the record includes the `"build.tarch": "xtensa"` property. */ -export interface Port { - readonly address: string; - readonly protocol: string; - readonly properties?: Record; - readonly hardwareId?: string; -} +export type BuildProperties = Readonly>; /** * The lightweight representation of all details of a particular board. See [`BoardDetailsResponse`](https://arduino.github.io/arduino-cli/latest/rpc/commands/#cc.arduino.cli.commands.v1.BoardDetailsResponse) for the CLI API. * @alpha */ -export interface BoardDetails { - readonly fqbn: string; - readonly requiredTools: Tool[]; - readonly configOptions: ConfigOption[]; - readonly programmers: Programmer[]; - readonly VID: string; - readonly PID: string; +export interface BoardDetails + extends Readonly< + Pick + > { + readonly toolsDependencies: Tool[]; + readonly buildProperties: BuildProperties; } /** * Required Tool dependencies of a board. See [`ToolsDependencies`](https://arduino.github.io/arduino-cli/latest/rpc/commands/#cc.arduino.cli.commands.v1.ToolsDependencies) for the CLI API. * @alpha */ -export interface Tool { - readonly packager: string; - readonly name: string; - readonly version: Version; -} - -/** - * The board's custom configuration options. See [`ConfigOption`](https://arduino.github.io/arduino-cli/latest/rpc/commands/#cc.arduino.cli.commands.v1.ConfigOption) for the CLI API. - * @alpha - */ -export interface ConfigOption { - readonly option: string; - readonly label: string; - readonly values: ConfigValue[]; -} - -/** - * Programmer supported by the board. See [`Programmer`](https://arduino.github.io/arduino-cli/latest/rpc/commands/#cc.arduino.cli.commands.v1.Programmer) for the CLI API. - * @alpha - */ -export interface Programmer { - readonly name: string; - readonly platform: string; - readonly id: string; -} +export type Tool = Readonly< + Pick +>; /** - * Value of the configuration option. See [`ConfigValue`](https://arduino.github.io/arduino-cli/latest/rpc/commands/#cc.arduino.cli.commands.v1.ConfigValue) for the CLI API. + * Summary of a sketch compilation. See [`CompileResponse`](https://arduino.github.io/arduino-cli/latest/rpc/commands/#compileresponse) for the CLI API. * @alpha */ -export interface ConfigValue { - readonly label: string; - readonly value: string; - readonly selected: boolean; +export interface CompileSummary + extends Readonly< + Pick< + CompileResponse, + | 'buildPath' + | 'usedLibraries' + | 'executableSectionsSize' + | 'boardPlatform' + | 'buildPlatform' + > + > { + readonly buildProperties: BuildProperties; } diff --git a/src/arduinoContext.ts b/src/arduinoContext.ts index 61a9787..0f870cf 100644 --- a/src/arduinoContext.ts +++ b/src/arduinoContext.ts @@ -1,29 +1,49 @@ -import * as vscode from 'vscode'; -import type { ArduinoContext, ArduinoState, BoardDetails, Port } from './api'; +import stringify from 'safe-stable-stringify'; +import vscode from 'vscode'; +import type { + ArduinoContext, + ArduinoState, + BoardDetails, + CompileSummary, + Port, +} from './api'; export function createArduinoContext( - workspaceState: vscode.Memento + state: vscode.Memento ): ArduinoContext & vscode.Disposable { - let debugOutput: vscode.OutputChannel | undefined = undefined; - const log = (message: string) => { - if (!debugOutput) { - debugOutput = vscode.window.createOutputChannel('VS Code Arduino API'); + // config + let log = false; + let compareBeforeUpdate = true; + const updateLog = () => (log = getWorkspaceConfig('log')); + const updateCompareBeforeUpdate = () => + (compareBeforeUpdate = getWorkspaceConfig('compareBeforeUpdate')); + updateLog(); + updateCompareBeforeUpdate(); + + // output channel + let logOutput: vscode.OutputChannel | undefined = undefined; + const debug = (message: string) => { + if (log) { + if (!logOutput) { + logOutput = vscode.window.createOutputChannel('Arduino API'); + } + logOutput.appendLine(message); } - debugOutput.appendLine(message); - }; - const get = (key: keyof ArduinoState) => getState(workspaceState, key); - const update = async ( - key: keyof ArduinoState, - value: ArduinoState[keyof ArduinoState] - ) => { - await updateState(workspaceState, key, value); - log(`Updated state of '${key}': ${JSON.stringify(value)}`); - emitters[key].fire(value); }; + + // events + let disposed = false; const emitters = createEmitters(); const onDidChange = createOnDidChange(emitters); const toDispose: vscode.Disposable[] = [ - new vscode.Disposable(() => debugOutput?.dispose()), + vscode.workspace.onDidChangeConfiguration(({ affectsConfiguration }) => { + if (affectsConfiguration('arduinoAPI.log')) { + updateLog(); + } else if (affectsConfiguration('arduinoAPI.compareBeforeUpdate')) { + updateCompareBeforeUpdate(); + } + }), + new vscode.Disposable(() => logOutput?.dispose()), vscode.commands.registerCommand(updateStateCommandId, (args: unknown) => { if (isUpdateStateParams(args)) { const { key, value } = args; @@ -33,20 +53,49 @@ export function createArduinoContext( try { invalidParams = JSON.stringify(args); } catch {} - log(`Ignored invalid state update: ${invalidParams}`); + throw new Error(`Invalid state update: ${invalidParams}`); } }), ...Object.values(emitters), ]; + + // state + const assertNotDisposed = () => { + if (disposed) { + throw new Error('Disposed'); + } + }; + const get = (key: keyof ArduinoState) => { + assertNotDisposed(); + return getState(state, key); + }; + const update = async ( + key: keyof ArduinoState, + value: ArduinoState[keyof ArduinoState] + ) => { + // the command does not exist if was disposed + assertNotDisposed(); + if (compareBeforeUpdate) { + const currentValue = get(key); + if (stringify(currentValue) === stringify(value)) { + return; + } + } + await updateState(state, key, value); + debug(`Updated '${key}': ${JSON.stringify(value)}`); + emitters[key].fire(value); + }; + + // context const arduinoContext: ArduinoContext & vscode.Disposable = { - get onDidChange() { - return onDidChange; + onDidChange(property: T) { + return onDidChange[property] as vscode.Event; }, get sketchPath() { return get('sketchPath'); }, - get buildPath() { - return get('buildPath'); + get compileSummary() { + return get('compileSummary'); }, get fqbn() { return get('fqbn'); @@ -64,7 +113,11 @@ export function createArduinoContext( return get('dataDirPath'); }, dispose(): void { + if (disposed) { + return; + } vscode.Disposable.from(...toDispose).dispose(); + disposed = true; }, }; return arduinoContext; @@ -74,10 +127,7 @@ function createOnDidChange( emitters: ReturnType ): Record> { const record = < - Record< - keyof ArduinoState, - vscode.Event - > + Record> >{}; return Object.entries(emitters).reduce((acc, [name, value]) => { const key = name; @@ -97,33 +147,29 @@ function createEmitters(): Record< } function getState( - workspaceState: vscode.Memento, + state: vscode.Memento, key: keyof ArduinoContext ): T | undefined { - return workspaceState.get(key); -} - -/** - * (non-API) - */ -export const updateStateCommandId = 'vscodeArduinoAPI.updateState'; - -interface UpdateStateParams { - readonly key: keyof ArduinoState; - readonly value: ArduinoState[keyof ArduinoState]; + return state.get(key); } async function updateState( - workspaceState: vscode.Memento, + state: vscode.Memento, key: keyof ArduinoState, value: ArduinoState[keyof ArduinoState] ): Promise { - return workspaceState.update(key, value); + return state.update(key, value); +} + +const updateStateCommandId = 'arduinoAPI.updateState'; +interface UpdateStateParams { + readonly key: keyof ArduinoState; + readonly value: ArduinoState[keyof ArduinoState]; } const noopArduinoState: ArduinoState = { sketchPath: undefined, - buildPath: undefined, + compileSummary: undefined, fqbn: undefined, boardDetails: undefined, port: undefined, @@ -140,8 +186,31 @@ function isUpdateStateParams(arg: unknown): arg is UpdateStateParams { (arg).key !== undefined && typeof (arg).key === 'string' && arduinoStateKeys.includes((arg).key) && - 'value' in arg + 'value' in arg // TODO: assert value correctness ); } return false; } + +const configKeys = ['log', 'compareBeforeUpdate'] as const; +type ConfigKey = (typeof configKeys)[number]; +const defaultConfigValues = { + log: false, + compareBeforeUpdate: true, +} as const; + +function getWorkspaceConfig(configKey: ConfigKey): T { + const defaultValue = defaultConfigValues[configKey]; + return vscode.workspace + .getConfiguration('arduinoAPI') + .get(configKey, defaultValue as unknown as T); +} + +/** + * (non-API) + */ +export const __test = { + updateStateCommandId, + defaultConfigValues, + getWorkspaceConfig, +} as const; diff --git a/src/extension.ts b/src/extension.ts index 31d6910..968702d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,9 @@ -import * as vscode from 'vscode'; +import vscode from 'vscode'; import { createArduinoContext } from './arduinoContext'; +import { InmemoryState } from './inmemoryState'; export function activate(context: vscode.ExtensionContext) { - const arduinoContext = createArduinoContext(context.workspaceState); + const arduinoContext = createArduinoContext(new InmemoryState()); // TODO: persist to the `workspaceState`? context.subscriptions.push(arduinoContext); return arduinoContext; } diff --git a/src/inmemoryState.ts b/src/inmemoryState.ts new file mode 100644 index 0000000..e7c771c --- /dev/null +++ b/src/inmemoryState.ts @@ -0,0 +1,30 @@ +import type { Memento } from 'vscode'; + +export class InmemoryState implements Memento { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly state: Record; + + constructor() { + this.state = {}; + } + + keys(): readonly string[] { + return Object.keys(this.state); + } + + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: unknown): T | undefined { + return this.state[key] ?? defaultValue; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + update(key: string, value: any): Thenable { + if (value === undefined) { + delete this.state[key]; + } else { + this.state[key] = value; + } + return Promise.resolve(); + } +} diff --git a/src/test/runTest.ts b/src/test/runTest.ts index eed707b..3dea32a 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -1,19 +1,15 @@ -import * as path from 'path'; - +import path from 'node:path'; import { runTests } from '@vscode/test-electron'; async function main() { try { - // The folder containing the Extension Manifest package.json - // Passed to `--extensionDevelopmentPath` const extensionDevelopmentPath = path.resolve(__dirname, '../../'); - - // The path to test runner - // Passed to --extensionTestsPath const extensionTestsPath = path.resolve(__dirname, './suite/index'); - - // Download VS Code, unzip it and run the integration test - await runTests({ extensionDevelopmentPath, extensionTestsPath }); + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [path.join(__dirname, '../../src/test/test-workspace')], // open workspace in VS Code + }); } catch (err) { console.error('Failed to run tests', err); process.exit(1); diff --git a/src/test/suite/arduinoContext.test.ts b/src/test/suite/arduinoContext.test.ts new file mode 100644 index 0000000..1658b61 --- /dev/null +++ b/src/test/suite/arduinoContext.test.ts @@ -0,0 +1,259 @@ +import assert from 'assert'; +import vscode from 'vscode'; +import type { + ArduinoContext, + ArduinoState, + BoardDetails, + CompileSummary, + Port, +} from '../../api'; +import { __test } from '../../arduinoContext'; + +const { updateStateCommandId, defaultConfigValues, getWorkspaceConfig } = + __test; + +const extensionId = 'dankeboy36.vscode-arduino-api'; + +const port = { + address: 'address', + label: 'port label', + protocol: 'serial', + protocolLabel: 'serial port', + properties: { + alma: 'korte', + one: 'two', + }, + hardwareId: '1730323', +}; +const samePort = { + ...port, + hardwareId: port.hardwareId, + properties: { + one: 'two', + alma: 'korte', + }, +}; +const boardDetails = { + configOptions: [], + fqbn: 'a:b:c', + programmers: [ + { id: 'one', name: 'one', platform: 'one' }, + { id: 'two', name: 'two', platform: 'two' }, + ], + toolsDependencies: [ + { name: 'a', packager: 'a', version: '1' }, + { name: 'b', packager: 'b', version: '2' }, + ], + buildProperties: { 'build.tarch': 'xtensa', x: 'y' }, +}; +const sameBoardDetails = { + ...boardDetails, + fqbn: boardDetails.fqbn, + toolsDependencies: boardDetails.toolsDependencies.slice().reverse(), + programmers: boardDetails.programmers.slice().reverse(), + buildProperties: { x: 'y', 'build.tarch': 'xtensa' }, +}; + +describe('arduinoContext', () => { + let arduinoContext: ArduinoContext; + const toDispose: vscode.Disposable[] = []; + + before(async () => { + const extension = vscode.extensions.getExtension(extensionId); + assert.notEqual(extension, undefined); + await extension?.activate(); + arduinoContext = extension?.exports; + assert.notEqual(arduinoContext, undefined); + }); + + after(() => vscode.Disposable.from(...toDispose).dispose()); + + const suite: Record = { + boardDetails, + compileSummary: { + buildPath: 'path/to/build/folder', + usedLibraries: [], + boardPlatform: undefined, + buildPlatform: undefined, + buildProperties: { 'build.tarch': 'xtensa' }, + executableSectionsSize: [], + }, + dataDirPath: 'path/to/directories.data', + userDirPath: 'path/to/directories.user', + fqbn: 'a:b:c', + sketchPath: 'path/to/sketch', + port, + }; + + Object.entries(suite).map(([name, expectedValue]) => + it(`should get and update '${name}' changes and receive an event on change`, async function () { + this.slow(250); + const property = name; + const value = arduinoContext[property]; + assert.deepEqual(value, undefined); + const values: ArduinoState[keyof ArduinoState][] = []; + toDispose.push( + arduinoContext.onDidChange(property)((newValue) => { + values.push(newValue); + }) + ); + await update(property, expectedValue); + const newValue = arduinoContext[property]; + assert.deepEqual(newValue, expectedValue); + assert.deepEqual(values.length, 1); + assert.deepEqual(values[0], expectedValue); + }) + ); + + it('should error when updating with invalid params', async () => { + const property = '♥'; + await assert.rejects( + update(property, 'manó'), + /Invalid state update: {"key":"♥","value":"manó"}/ + ); + }); + + it('should gracefully handle when when updating with invalid params', async () => { + const circular = { b: 1, a: 0 }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (circular as any).circular = circular; + const property = 'circular'; + await assert.rejects( + update(property, circular), + /Invalid state update: \[object Object\]/ + ); + }); + + interface ConfigTest { + readonly configKey: Parameters[0]; + readonly defaultValue: unknown; + readonly testValues: unknown[]; + } + const testValues: Record = { + log: [true, false], + compareBeforeUpdate: [true, false], + }; + const configTests: ConfigTest[] = Object.entries(defaultConfigValues).map( + ([key, value]) => ({ + configKey: key as ConfigTest['configKey'], + defaultValue: value, + testValues: testValues[key as ConfigTest['configKey']], + }) + ); + + configTests.map((configTest) => + it(`should support the 'arduinoAPI.${configTest.configKey}' configuration`, async () => { + const { configKey, defaultValue, testValues } = configTest; + await updateWorkspaceConfig(configKey, undefined); + const actualInspect = vscode.workspace + .getConfiguration('arduinoAPI') + .inspect(configKey); + assert.notEqual(actualInspect, undefined); + assert.equal(actualInspect?.defaultValue, defaultValue); + for (const testValue of testValues) { + await updateWorkspaceConfig(configKey, testValue); + const actualValue = getWorkspaceConfig(configKey); + assert.equal( + actualValue, + testValue, + `failed to get expected config value for '${configKey}'` + ); + } + }) + ); + + interface StateUpdateTest { + property: keyof T; + value: T[keyof T]; + sameValue: T[keyof T]; + } + const stateUpdateTests: StateUpdateTest[] = [ + { property: 'port', value: port, sameValue: samePort }, + { + property: 'boardDetails', + value: boardDetails, + sameValue: sameBoardDetails, + }, + ]; + stateUpdateTests.map((updateTest) => + it(`should ignore same value updates when 'compareBeforeUpdate' is 'true' (${updateTest.property})`, async function () { + const { property, value, sameValue } = updateTest; + if (property === 'boardDetails') { + // Fail when enhancement is implemented in the CLI. + // https://github.com/arduino/arduino-cli/issues/2209 + assert.notDeepEqual(value, sameValue); + } else { + assert.deepEqual(value, sameValue); + } + await update(property, value); + assert.deepEqual(arduinoContext[property], value); + + const updates: (typeof value | undefined)[] = []; + toDispose.push( + arduinoContext.onDidChange(property)((newValue) => { + updates.push(newValue); + }) + ); + + await updateWorkspaceConfig('compareBeforeUpdate', true); + await update(property, value); + assert.equal(updates.length, 0); + await update(property, sameValue); + if (property === 'boardDetails') { + // See special handling of `boardDetails` above. + assert.equal(updates.length, 1, JSON.stringify(updates)); + return this.skip(); + } + assert.equal(updates.length, 0, JSON.stringify(updates[0])); + + await updateWorkspaceConfig('compareBeforeUpdate', false); + await update(property, sameValue); + assert.equal(updates.length, 1); + assert.deepEqual(updates[0], sameValue); + assert.deepEqual(updates[0], value); + + await updateWorkspaceConfig('compareBeforeUpdate', true); + await update(property, undefined); + assert.equal(updates.length, 2); + assert.deepEqual(updates[1], undefined); + await update(property, sameValue); + assert.equal(updates.length, 3); + assert.deepEqual(updates[2], sameValue); + assert.deepEqual(updates[2], value); + + assert.deepEqual(arduinoContext[property], sameValue); + }) + ); + + it('should error when disposed', async () => { + assert.equal('dispose' in arduinoContext, true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const disposable = <{ dispose: unknown }>(arduinoContext as any); + assert.equal(typeof disposable.dispose === 'function', true); + (<{ dispose(): unknown }>disposable).dispose(); + + assert.throws(() => arduinoContext.fqbn, /Disposed/); + await assert.rejects( + update('fqbn', undefined), + (reason) => + reason instanceof Error && + reason.message === `command '${updateStateCommandId}' not found` + ); + }); + + async function update( + key: keyof T, + value: T[keyof T] + ): Promise { + return vscode.commands.executeCommand(updateStateCommandId, { key, value }); + } + + async function updateWorkspaceConfig( + configKey: ConfigTest['configKey'], + value: unknown + ): Promise { + return vscode.workspace + .getConfiguration('arduinoAPI') + .update(configKey, value); + } +}); diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts deleted file mode 100644 index 566d483..0000000 --- a/src/test/suite/extension.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import assert from 'assert'; -import { after } from 'mocha'; -import * as vscode from 'vscode'; -import type { ArduinoContext, ArduinoState, BoardDetails } from '../../api'; -import { updateStateCommandId } from '../../arduinoContext'; - -const extensionId = 'dankeboy36.vscode-arduino-api'; - -describe('arduinoContext', () => { - let arduinoContext: ArduinoContext; - const toDispose: vscode.Disposable[] = []; - - before(async () => { - const extension = vscode.extensions.getExtension(extensionId); - assert.notEqual(extension, undefined); - await extension?.activate(); - arduinoContext = extension?.exports; - assert.notEqual(arduinoContext, undefined); - }); - - after(() => vscode.Disposable.from(...toDispose).dispose()); - - const suite: Record = { - boardDetails: { - PID: '', - VID: '', - configOptions: [], - fqbn: 'a:b:c', - programmers: [], - requiredTools: [], - }, - buildPath: 'path/to/build/folder', - dataDirPath: 'path/to/directories.data', - userDirPath: 'path/to/directories.user', - fqbn: 'a:b:c', - sketchPath: 'path/to/sketch', - port: undefined, - }; - - Object.entries(suite).map(([name, expectedValue]) => - it(`should get and update '${name}' changes and receive an event on change`, async function () { - this.slow(250); - const key = name; - const value = arduinoContext[key]; - assert.deepEqual(value, undefined); - const values: ArduinoState[keyof ArduinoState][] = []; - toDispose.push( - arduinoContext.onDidChange[key]((newValue) => { - values.push(newValue); - }) - ); - await update(key, expectedValue); - const newValue = arduinoContext[key]; - assert.deepEqual(newValue, expectedValue); - assert.deepEqual(values.length, 1); - assert.deepEqual(values[0], expectedValue); - }) - ); - - async function update( - key: keyof T, - value: T[keyof T] - ): Promise { - return vscode.commands.executeCommand(updateStateCommandId, { key, value }); - } -}); diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index d9bddeb..d5b1a16 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -1,38 +1,39 @@ -import path from 'path'; -import Mocha from 'mocha'; import glob from 'glob'; +import Mocha from 'mocha'; +import path from 'node:path'; -export function run(): Promise { - // Create the mocha test +export async function run(): Promise { const mocha = new Mocha({ ui: 'bdd', color: true, + timeout: noTestTimeout() ? 0 : 10_000, }); - const testsRoot = path.resolve(__dirname, '..'); - - return new Promise((c, e) => { + return new Promise((resolve, reject) => { glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { if (err) { - return e(err); + return reject(err); } - - // Add files to the test suite - files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); - + files.forEach((file) => mocha.addFile(path.resolve(testsRoot, file))); try { - // Run the mocha test mocha.run((failures) => { if (failures > 0) { - e(new Error(`${failures} tests failed.`)); + reject(new Error(`${failures} tests failed.`)); } else { - c(); + resolve(); } }); } catch (err) { console.error(err); - e(err); + reject(err); } }); }); } + +function noTestTimeout(): boolean { + return ( + typeof process.env.NO_TEST_TIMEOUT === 'string' && + /true/i.test(process.env.NO_TEST_TIMEOUT) + ); +} diff --git a/src/test/suite/inmemoryState.test.ts b/src/test/suite/inmemoryState.test.ts new file mode 100644 index 0000000..c61344b --- /dev/null +++ b/src/test/suite/inmemoryState.test.ts @@ -0,0 +1,54 @@ +import assert from 'assert'; +import { InmemoryState } from '../../inmemoryState'; + +describe('inmemoryState', () => { + it('should get a value', async () => { + const state = new InmemoryState(); + await state.update('alma', false); + assert.equal(state.get('alma'), false); + }); + + it('should get a value with a default', async () => { + const state = new InmemoryState(); + assert.equal(state.get('alma'), undefined); + assert.equal(state.get('alma', false), false); + }); + + it('should not modify the state when getting with default and the cache hits a miss', async () => { + const state = new InmemoryState(); + assert.equal(state.get('alma', false), false); + const actual = state.keys(); + assert.equal(actual.length, 0); + }); + + it('should retrieve the keys', async () => { + const state = new InmemoryState(); + await state.update('alma', false); + await state.update('korte', 36); + const actual = state.keys(); + assert.equal(actual.length, 2); + assert.equal(actual.includes('alma'), true); + assert.equal(actual.includes('korte'), true); + }); + + it('should update with new value', async () => { + const state = new InmemoryState(); + await state.update('alma', false); + assert.equal(state.get('alma'), false); + await state.update('alma', 36); + assert.equal(state.get('alma'), 36); + const actual = state.keys(); + assert.equal(actual.length, 1); + assert.equal(actual.includes('alma'), true); + }); + + it('should remove when the update value is undefined', async () => { + const state = new InmemoryState(); + await state.update('alma', false); + assert.equal(state.get('alma'), false); + await state.update('alma', undefined); + assert.equal(state.get('alma'), undefined); + const actual = state.keys(); + assert.equal(actual.length, 0); + }); +}); diff --git a/src/test/test-workspace/.gitkeep b/src/test/test-workspace/.gitkeep new file mode 100644 index 0000000..e69de29