diff --git a/.eslintrc.json b/.eslintrc.json index 601348a..52f6d9e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -56,6 +56,35 @@ // Mock stubs and disposables use empty function bodies as intentional no-ops. "@typescript-eslint/no-empty-function": "off" } + }, + { + "files": ["src/test/unit/**/*.ts"], + "rules": { + // Mock objects require any types for flexible test doubles. + "@typescript-eslint/no-explicit-any": "off", + // Mock stubs and disposables use empty function bodies as intentional no-ops. + "@typescript-eslint/no-empty-function": "off", + // Test mocks use Function type for generic callback parameters. + "@typescript-eslint/ban-types": "off", + // Mock properties don't need readonly annotations. + "@typescript-eslint/prefer-readonly": "off", + // Dynamic delete is used in mock client teardown. + "@typescript-eslint/no-dynamic-delete": "off", + // Tests use new for side effects (instantiating classes under test). + "no-new": "off", + // Padded blocks are acceptable in test organization. + "padded-blocks": "off", + // Truthy object checks are used in mock conditionals. + "@typescript-eslint/strict-boolean-expressions": "off", + // Mock registration requires require() before imports. + "@typescript-eslint/no-var-requires": "off", + // Cache setup objects are more readable on one line. + "object-property-newline": "off", + // Unused vars are common in test setup (instantiation triggers side effects). + "@typescript-eslint/no-unused-vars": "off", + // Type assertions used in mock wiring. + "@typescript-eslint/no-unnecessary-type-assertion": "off" + } } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index a66f186..189cb96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.13] - 2026-07-28 + +### Added +- Support for discovering and running MATLAB unit tests (Addresses [mathworks/MATLAB-extension-for-vscode#138](https://github.com/mathworks/MATLAB-extension-for-vscode/issues/138)) +- Support for syntax highlighting in the MATLAB terminal (Addresses [mathworks/MATLAB-extension-for-vscode#295](https://github.com/mathworks/MATLAB-extension-for-vscode/issues/295)) + +### Fixed +- Resolves an issue that prevents MATLAB workspace data from updating when a `clear` command is followed by a long-running operation, such as `figure`. (Addresses [mathworks/MATLAB-extension-for-vscode#332](https://github.com/mathworks/MATLAB-extension-for-vscode/issues/332)) +- Prevents starting the MATLAB language server in untrusted workspaces. +- Resolves an issue with telemetry not being reported when extension settings are changed. + ## [1.3.12] - 2026-06-15 ### Added diff --git a/README.md b/README.md index eb5783a..4c8a297 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ If you have MATLAB R2021b or later installed on your system, you have access to * Code analysis, such as continuous code checking and automatic fixes * Code outline * Symbol renaming +* Test discovery and execution ![MATLAB Extension Demo](public/AdvancedFeatures.gif) @@ -84,6 +85,17 @@ When a project is open, Visual Studio Code shows the project name in the status ![MATLAB Projects Screenshot](public/Projects.png) +## Run MATLAB Tests +If you have MATLAB R2021b or later installed on your system, you can run MATLAB unit tests using the Test Explorer in Visual Studio Code. To add tests to the Test Explorer, open the Testing view by selecting the Testing icon in the Activity Bar on the left side of the Visual Studio Code window, and then click **Add Test Folder** or **Add Test File** in the Test Explorer. You also can add tests using the `MATLAB: Add Test Folder` and `MATLAB: Add Test File` commands in the Command Palette. + +When you add a test folder, the extension adds all tests in that folder and its subfolders. Tests appear in a hierarchical tree organized by file, test procedure, and parameterization. The test tree updates automatically when test files are modified or when files are added to or removed from your test folders. The extension supports class-based tests (including parameterized tests), function-based tests, and script-based tests. + +To run tests, click **Run Test** to the right of a test or test file in the Test Explorer, or click **Run Tests** in the toolbar. Results appear in real time as each test completes, with icons indicating whether the test passed, failed, or remained incomplete. Additionally, as the tests run, test output appears in real time in the Test Results panel. + +If a test fails, click the failed test in the Test Explorer to navigate directly to the failing line in your test code and view test diagnostics. Alternatively, click the failed test in the Test Results panel to view diagnostic messages. + +![Test Explorer showing test results with passed and failed tests, and diagnostic output in the Test Results panel](public/RunTests.png) + ## Run MATLAB in Jupyter Notebooks You also can use this extension along with the Jupyter Extension for Visual Studio Code to run MATLAB in Jupyter notebooks using Visual Studio Code. For instructions, see [Run MATLAB in Jupyter Notebooks Using VS Code](https://github.com/mathworks/jupyter-matlab-proxy/blob/main/install_guides/vscode/README.md). diff --git a/package-lock.json b/package-lock.json index 7b21679..518491d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,19 @@ { "name": "language-matlab", - "version": "1.3.12", + "version": "1.3.13", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "language-matlab", - "version": "1.3.12", + "version": "1.3.13", "license": "MIT", "dependencies": { "@vscode/debugadapter": "^1.56.0", "node-fetch": "^2.6.6", - "vscode-languageclient": "^8.0.2" + "vscode-languageclient": "^8.0.2", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.3.2" }, "devDependencies": { "@types/chai": "^5.2.2", @@ -673,10 +675,11 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -768,6 +771,7 @@ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, + "license": "MIT", "optional": true, "engines": { "node": ">=14" @@ -1210,32 +1214,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1655,16 +1637,20 @@ } }, "node_modules/@vscode/vsce": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.19.0.tgz", - "integrity": "sha512-dAlILxC5ggOutcvJY24jxz913wimGiUrHaPkk16Gm9/PGFbz1YezWtrXsTKUtJws4fIlpX2UIlVlVESWq8lkfQ==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.32.0.tgz", + "integrity": "sha512-3EFJfsgrSftIqt3EtdRcAygy/OJ3hstyI1cDmIgkU9CFZW5C+3djr6mfosndCUqcVYuyjmxOK1xmFp/Bq7+NIg==", "dev": true, "license": "MIT", "dependencies": { - "azure-devops-node-api": "^11.0.1", + "@azure/identity": "^4.1.0", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", "chalk": "^2.4.2", "cheerio": "^1.0.0-rc.9", - "commander": "^6.1.0", + "cockatiel": "^3.1.2", + "commander": "^6.2.1", + "form-data": "^4.0.0", "glob": "^7.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", @@ -1674,7 +1660,7 @@ "minimatch": "^3.0.3", "parse-semver": "^1.1.1", "read": "^1.0.7", - "semver": "^5.1.0", + "semver": "^7.5.2", "tmp": "^0.2.1", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", @@ -1686,7 +1672,7 @@ "vsce": "vsce" }, "engines": { - "node": ">= 14" + "node": ">= 16" }, "optionalDependencies": { "keytar": "^7.7.0" @@ -1839,17 +1825,6 @@ "node": ">=4" } }, - "node_modules/@vscode/vsce/node_modules/azure-devops-node-api": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-11.2.0.tgz", - "integrity": "sha512-XdiGPhrpaT5J8wdERRKs5g8E0Zy1pvOYTli7z9E8nmOn3YGp4FhtjhrOyFmX/8veWCwdI69mCHKJw6l+4J/bHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tunnel": "0.0.6", - "typed-rest-client": "^1.8.4" - } - }, "node_modules/@vscode/vsce/node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -1888,6 +1863,23 @@ "node": ">=0.8.0" } }, + "node_modules/@vscode/vsce/node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@vscode/vsce/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1919,16 +1911,6 @@ "node": ">=4" } }, - "node_modules/@vscode/vsce/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, "node_modules/@vscode/vsce/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2245,9 +2227,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -2273,6 +2255,7 @@ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2322,6 +2305,7 @@ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, + "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -2330,6 +2314,16 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2557,9 +2551,9 @@ "optional": true }, "node_modules/baseline-browser-mapping": { - "version": "2.10.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", - "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2580,12 +2574,16 @@ } }, "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/binaryextensions": { @@ -2651,9 +2649,9 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2814,6 +2812,7 @@ "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", "dev": true, + "license": "ISC", "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", @@ -2847,6 +2846,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -2857,10 +2857,11 @@ } }, "node_modules/c8/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -2879,6 +2880,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { "node": ">=12" } @@ -3022,9 +3024,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", "dev": true, "funding": [ { @@ -3153,6 +3155,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -3160,6 +3163,16 @@ "node": ">= 6" } }, + "node_modules/chokidar/node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -3856,9 +3869,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.339", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", - "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==", + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", "dev": true, "license": "ISC" }, @@ -3888,24 +3901,25 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" } }, "node_modules/entities": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", - "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -4027,9 +4041,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -4655,6 +4669,7 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.x" } @@ -4740,9 +4755,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -4888,15 +4903,16 @@ } }, "node_modules/form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", - "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz", + "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", + "hasown": "^2.0.4", "mime-types": "^2.1.35" }, "engines": { @@ -4939,11 +4955,12 @@ "dev": true }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -5136,9 +5153,9 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -5353,10 +5370,11 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -5701,6 +5719,7 @@ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, + "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" }, @@ -6317,10 +6336,21 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -6591,9 +6621,9 @@ } }, "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "dev": true, "license": "MIT", "engines": { @@ -6938,10 +6968,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -6970,6 +7001,7 @@ "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", @@ -7001,9 +7033,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -7059,19 +7091,6 @@ "dev": true, "license": "ISC" }, - "node_modules/mock-require/node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7124,9 +7143,9 @@ } }, "node_modules/nise/node_modules/@sinonjs/fake-timers": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", - "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7199,11 +7218,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/node-sarif-builder": { "version": "3.4.0", @@ -7289,10 +7311,14 @@ "license": "ISC" }, "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", "dev": true, + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, "engines": { "node": ">=0.10.0" } @@ -7349,9 +7375,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", "dev": true, "license": "MIT" }, @@ -7864,16 +7890,17 @@ "dev": true }, "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8280,6 +8307,7 @@ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, + "license": "MIT", "dependencies": { "picomatch": "^2.2.1" }, @@ -8707,9 +8735,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "peer": true, @@ -9513,9 +9541,9 @@ "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -9660,9 +9688,9 @@ } }, "node_modules/terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -9679,9 +9707,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", - "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9701,12 +9729,39 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } @@ -9745,9 +9800,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -9761,7 +9816,9 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -9785,9 +9842,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -9815,6 +9872,7 @@ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -9829,16 +9887,17 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -9852,6 +9911,7 @@ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -9971,9 +10031,9 @@ } }, "node_modules/ts-loader": { - "version": "9.5.7", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", - "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9987,8 +10047,14 @@ "node": ">=12.0.0" }, "peerDependencies": { + "loader-utils": "*", "typescript": "*", - "webpack": "^5.0.0" + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } } }, "node_modules/tsconfig-paths": { @@ -10396,9 +10462,9 @@ } }, "node_modules/vscode-extension-tester/node_modules/@vscode/vsce": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz", - "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", "dev": true, "license": "MIT", "dependencies": { @@ -10414,13 +10480,13 @@ "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", - "glob": "^11.0.0", + "glob": "^13.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", - "minimatch": "^3.0.3", + "minimatch": "^10.2.2", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", @@ -10429,7 +10495,7 @@ "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", - "yauzl": "^2.3.1", + "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "bin": { @@ -10451,6 +10517,24 @@ "node": ">=18" } }, + "node_modules/vscode-extension-tester/node_modules/@vscode/vsce/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/vscode-extension-tester/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -10462,9 +10546,9 @@ } }, "node_modules/vscode-extension-tester/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -10501,16 +10585,17 @@ } }, "node_modules/vscode-extension-tester/node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -10539,27 +10624,22 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/vscode-extension-tester/node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/vscode-extension-tester/node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } @@ -10580,15 +10660,25 @@ } }, "node_modules/vscode-extension-tester/node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -10603,6 +10693,22 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "dev": true }, + "node_modules/vscode-extension-tester/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/vscode-extension-tester/node_modules/p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", @@ -10646,7 +10752,21 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-extension-tester/node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } }, "node_modules/vscode-extension-tester/node_modules/yocto-queue": { "version": "1.1.1", @@ -10682,9 +10802,9 @@ } }, "node_modules/vscode-languageclient/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -10716,6 +10836,18 @@ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz", "integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==" }, + "node_modules/vscode-oniguruma": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", + "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==", + "license": "MIT" + }, + "node_modules/vscode-textmate": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz", + "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -10749,14 +10881,13 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.106.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", - "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", @@ -10766,20 +10897,20 @@ "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.1", + "loader-runner": "^4.3.2", "mime-db": "^1.54.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", + "terser-webpack-plugin": "^5.5.0", "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" + "webpack-sources": "^3.5.0" }, "bin": { "webpack": "bin/webpack.js" @@ -10867,9 +10998,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", "dev": true, "license": "MIT", "engines": { @@ -11041,7 +11172,8 @@ "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/wrap-ansi": { "version": "7.0.0", @@ -11690,9 +11822,9 @@ } }, "@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true }, "@jridgewell/gen-mapping": { @@ -12106,30 +12238,10 @@ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true }, - "@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "requires": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true }, "@types/glob": { @@ -12431,15 +12543,19 @@ } }, "@vscode/vsce": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.19.0.tgz", - "integrity": "sha512-dAlILxC5ggOutcvJY24jxz913wimGiUrHaPkk16Gm9/PGFbz1YezWtrXsTKUtJws4fIlpX2UIlVlVESWq8lkfQ==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.32.0.tgz", + "integrity": "sha512-3EFJfsgrSftIqt3EtdRcAygy/OJ3hstyI1cDmIgkU9CFZW5C+3djr6mfosndCUqcVYuyjmxOK1xmFp/Bq7+NIg==", "dev": true, "requires": { - "azure-devops-node-api": "^11.0.1", + "@azure/identity": "^4.1.0", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", "chalk": "^2.4.2", "cheerio": "^1.0.0-rc.9", - "commander": "^6.1.0", + "cockatiel": "^3.1.2", + "commander": "^6.2.1", + "form-data": "^4.0.0", "glob": "^7.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", @@ -12450,7 +12566,7 @@ "minimatch": "^3.0.3", "parse-semver": "^1.1.1", "read": "^1.0.7", - "semver": "^5.1.0", + "semver": "^7.5.2", "tmp": "^0.2.1", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", @@ -12468,16 +12584,6 @@ "color-convert": "^1.9.0" } }, - "azure-devops-node-api": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-11.2.0.tgz", - "integrity": "sha512-XdiGPhrpaT5J8wdERRKs5g8E0Zy1pvOYTli7z9E8nmOn3YGp4FhtjhrOyFmX/8veWCwdI69mCHKJw6l+4J/bHA==", - "dev": true, - "requires": { - "tunnel": "0.0.6", - "typed-rest-client": "^1.8.4" - } - }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -12510,6 +12616,19 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true }, + "form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + } + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -12530,12 +12649,6 @@ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true }, - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true - }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -12855,9 +12968,9 @@ }, "dependencies": { "ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "requires": { "fast-deep-equal": "^3.1.3", @@ -12912,6 +13025,14 @@ "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" + }, + "dependencies": { + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + } } }, "argparse": { @@ -13062,9 +13183,9 @@ "optional": true }, "baseline-browser-mapping": { - "version": "2.10.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", - "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", "dev": true }, "big.js": { @@ -13074,9 +13195,9 @@ "dev": true }, "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true }, "binaryextensions": { @@ -13133,9 +13254,9 @@ "dev": true }, "brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "requires": { "balanced-match": "^1.0.0", @@ -13271,9 +13392,9 @@ } }, "yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "requires": { "cliui": "^8.0.1", @@ -13383,9 +13504,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", "dev": true }, "chai": { @@ -13470,6 +13591,12 @@ "requires": { "is-glob": "^4.0.1" } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true } } }, @@ -13958,9 +14085,9 @@ } }, "electron-to-chromium": { - "version": "1.5.339", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", - "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==", + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", "dev": true }, "emoji-regex": { @@ -13985,19 +14112,19 @@ } }, "enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", "dev": true, "requires": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" } }, "entities": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", - "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true }, "envinfo": { @@ -14087,9 +14214,9 @@ "dev": true }, "es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true }, "es-object-atoms": { @@ -14585,9 +14712,9 @@ "dev": true }, "fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true }, "fastest-levenshtein": { @@ -14685,15 +14812,15 @@ } }, "form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", - "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz", + "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==", "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", + "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, @@ -14727,9 +14854,9 @@ "dev": true }, "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "optional": true }, @@ -14843,9 +14970,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -15001,9 +15128,9 @@ } }, "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "requires": { "function-bind": "^1.1.2" @@ -15612,9 +15739,9 @@ "dev": true }, "js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "requires": { "argparse": "^2.0.1" @@ -15836,9 +15963,9 @@ } }, "loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "dev": true }, "loader-utils": { @@ -16079,9 +16206,9 @@ "dev": true }, "minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true }, "mkdirp": { @@ -16126,9 +16253,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -16169,15 +16296,6 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", "dev": true - }, - "normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "requires": { - "remove-trailing-separator": "^1.0.1" - } } } }, @@ -16231,9 +16349,9 @@ }, "dependencies": { "@sinonjs/fake-timers": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", - "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "requires": { "@sinonjs/commons": "^3.0.1" @@ -16282,9 +16400,9 @@ } }, "node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true }, "node-sarif-builder": { @@ -16362,10 +16480,13 @@ } }, "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } }, "normalize-url": { "version": "8.0.1", @@ -16400,9 +16521,9 @@ } }, "nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", "dev": true }, "object-inspect": { @@ -16747,9 +16868,9 @@ "dev": true }, "path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "requires": { "lru-cache": "^11.0.0", @@ -17326,9 +17447,9 @@ }, "dependencies": { "ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "peer": true, "requires": { @@ -17865,9 +17986,9 @@ } }, "tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true }, "tar-fs": { @@ -17989,9 +18110,9 @@ } }, "terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "requires": { "@jridgewell/source-map": "^0.3.3", @@ -18009,9 +18130,9 @@ } }, "terser-webpack-plugin": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", - "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", "dev": true, "requires": { "@jridgewell/trace-mapping": "^0.3.25", @@ -18038,9 +18159,9 @@ "dev": true }, "brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "requires": { "balanced-match": "^4.0.2" @@ -18067,9 +18188,9 @@ "dev": true }, "brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -18103,12 +18224,12 @@ "dev": true }, "minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "requires": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" } }, "path-scurry": { @@ -18208,9 +18329,9 @@ } }, "ts-loader": { - "version": "9.5.7", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", - "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", "dev": true, "requires": { "chalk": "^4.1.0", @@ -18506,9 +18627,9 @@ }, "dependencies": { "@vscode/vsce": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz", - "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", "dev": true, "requires": { "@azure/identity": "^4.1.0", @@ -18523,14 +18644,14 @@ "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", - "glob": "^11.0.0", + "glob": "^13.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "keytar": "^7.7.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", - "minimatch": "^3.0.3", + "minimatch": "^10.2.2", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", @@ -18539,7 +18660,7 @@ "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", - "yauzl": "^2.3.1", + "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "dependencies": { @@ -18548,6 +18669,17 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } } } }, @@ -18558,9 +18690,9 @@ "dev": true }, "brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "requires": { "balanced-match": "^4.0.2" @@ -18584,16 +18716,16 @@ } }, "form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" } }, "glob": { @@ -18608,23 +18740,12 @@ "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" - }, - "dependencies": { - "minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "requires": { - "brace-expansion": "^5.0.2" - } - } } }, "linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, "requires": { "uc.micro": "^2.0.0" @@ -18640,14 +18761,14 @@ } }, "markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "dev": true, "requires": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -18659,6 +18780,15 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "dev": true }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, "p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", @@ -18689,6 +18819,15 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, + "yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, "yocto-queue": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", @@ -18713,9 +18852,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "requires": { "balanced-match": "^1.0.0" } @@ -18744,6 +18883,16 @@ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz", "integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==" }, + "vscode-oniguruma": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", + "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==" + }, + "vscode-textmate": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz", + "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==" + }, "w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -18769,13 +18918,12 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { - "version": "5.106.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", - "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", "dev": true, "peer": true, "requires": { - "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", @@ -18785,20 +18933,20 @@ "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.1", + "loader-runner": "^4.3.2", "mime-db": "^1.54.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", + "terser-webpack-plugin": "^5.5.0", "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" + "webpack-sources": "^3.5.0" }, "dependencies": { "mime-db": { @@ -18851,9 +18999,9 @@ } }, "webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", "dev": true }, "whatwg-encoding": { diff --git a/package.json b/package.json index efaf98e..58e7f0c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Edit MATLAB code with syntax highlighting, linting, navigation support, and more", "icon": "public/L-Membrane_RGB_128x128.png", "license": "MIT", - "version": "1.3.12", + "version": "1.3.13", "engines": { "vscode": "^1.67.0" }, @@ -27,6 +27,13 @@ "onTerminalProfile:matlab.terminal-profile" ], "main": "./out/extension.js", + "capabilities": { + "untrustedWorkspaces": { + "supported": "limited", + "description": "MATLAB language server cannot run in an untrusted workspace.", + "restrictedConfigurations": ["MATLAB.installPath"] + } + }, "contributes": { "commands": [ { @@ -124,6 +131,31 @@ { "command": "matlab.project.actions", "title": "MATLAB: Project Actions..." + }, + { + "command": "matlab.testing.runAllTests", + "title": "MATLAB: Run All Tests" + }, + { + "command": "matlab.testing.discoverTests", + "title": "MATLAB: Discover Tests" + }, + { + "command": "matlab.testing.addTestFolder", + "title": "Add Test Folder", + "category": "MATLAB", + "icon": "$(new-folder)" + }, + { + "command": "matlab.testing.addTestFile", + "title": "Add Test File", + "category": "MATLAB", + "icon": "$(new-file)" + }, + { + "command": "matlab.testing.removeTestItem", + "title": "Remove Test", + "icon": "$(trash)" } ], "keybindings": [ @@ -183,6 +215,13 @@ ] } ], + "viewsWelcome": [ + { + "view": "testing", + "contents": "[Add Test Folder](command:matlab.testing.addTestFolder)\n[Add Test File](command:matlab.testing.addTestFile)", + "when": "matlab.testing.isActive" + } + ], "menus": { "commandPalette": [ { @@ -197,6 +236,10 @@ "command": "matlab.changeDirectory", "when": "false" }, + { + "command": "matlab.testing.removeTestItem", + "when": "false" + }, { "command": "matlab.wsb.editValue", "when": "false" @@ -218,6 +261,25 @@ "when": "false" } ], + "testing/item/context": [ + { + "command": "matlab.testing.removeTestItem", + "group": "matlab@1", + "when": "controllerId == matlab-tests" + } + ], + "view/title": [ + { + "command": "matlab.testing.addTestFolder", + "when": "view == workbench.view.testing && matlab.testing.isActive", + "group": "navigation@1" + }, + { + "command": "matlab.testing.addTestFile", + "when": "view == workbench.view.testing && matlab.testing.isActive", + "group": "navigation@2" + } + ], "editor/title/run": [ { "command": "matlab.runFile", @@ -515,7 +577,7 @@ "compile": "tsc -p ./ && npm run copy-wsb-resources && webpack && cd server && npm run compile && cd ..", "watch": "tsc -watch -p ./ && npm run copy-wsb-resources && cd server && npm run watch && webpack --watch && cd ..", "test-setup": "npm run compile && npm run lint && npm run copy-test-files && npm run copy-config-files", - "copy-test-files": "cd src && copyfiles ./test/test-files/**/*.m ./../out/ && cd ..", + "copy-test-files": "cd src && copyfiles ./test/test-files/**/*.m ./../out/ && copyfiles ./test/test-files/**/*.toml ./../out/ && copyfiles ./test/test-files/**/*.json ./../out/ && cd ..", "copy-config-files": "cd src && copyfiles ./test/tools/config/*.* ./../out/ -all && cd ..", "copy-wsb-resources": "copyfiles \"src/workspacebrowser/resources/**/*\" out/workspacebrowser/resources/ -u 3", "lint": "eslint src --ext ts", @@ -570,6 +632,8 @@ "dependencies": { "@vscode/debugadapter": "^1.56.0", "node-fetch": "^2.6.6", - "vscode-languageclient": "^8.0.2" + "vscode-languageclient": "^8.0.2", + "vscode-textmate": "^9.3.2", + "vscode-oniguruma": "^2.0.1" } } diff --git a/public/RunTests.png b/public/RunTests.png new file mode 100644 index 0000000..1f01bd6 Binary files /dev/null and b/public/RunTests.png differ diff --git a/server b/server index 046ae83..5f46919 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 046ae83001dadb25c27184e24edddee4afc52eb3 +Subproject commit 5f4691951d4ccac77f5cb72376b80cc77c54e2cc diff --git a/src/commandwindow/CommandWindow.ts b/src/commandwindow/CommandWindow.ts index b9e4d07..0e64907 100644 --- a/src/commandwindow/CommandWindow.ts +++ b/src/commandwindow/CommandWindow.ts @@ -1,6 +1,13 @@ // Copyright 2024-2026 The MathWorks, Inc. import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as fsPromise from 'fs/promises'; +import * as path from 'path'; + +import * as vscodeTextmate from 'vscode-textmate'; +import * as oniguruma from 'vscode-oniguruma'; + import { CompletionList } from 'vscode-languageclient'; import { Notifier } from './MultiClientNotifier'; @@ -9,6 +16,9 @@ import { TextEvent, PromptState } from './MVMInterface'; import Notification from '../notifications/Notifications'; import { createResolvablePromise, ResolvablePromise } from '../utils/ResolvablePromise'; +/* eslint-disable @typescript-eslint/strict-boolean-expressions */ +/* eslint-disable no-control-regex */ + /** * Direction of cursor movement */ @@ -69,8 +79,15 @@ const ACTION_KEYS = { RESTORE_COLORS: ESC + '[27m', RED_FOREGROUND: ESC + '[31m', YELLOW_FOREGROUND: ESC + '[33m', + DEFAULT_FOREGROUND: ESC + '[39m', ALL_DEFAULT_COLORS: ESC + '[0m', - + RGB_FOREGROUND: (hexStr: string) => { + const noHashStr: string = hexStr.substring(1); + const r = Number('0x' + noHashStr.substring(0, 2)).toString(); + const g = Number('0x' + noHashStr.substring(2, 4)).toString(); + const b = Number('0x' + noHashStr.substring(4, 6)).toString(); + return ESC + `[38;2;${r};${g};${b}m` + }, COPY: '\x03', PASTE: '\x16', @@ -81,15 +98,12 @@ const ACTION_KEYS = { QUERY_CURSOR: ESC + '[6n', SET_CURSOR_STYLE_TO_BAR: ESC + '[5 q' }; -// eslint-disable-next-line no-control-regex + const LEFT_REGEX = /^(\x1b\[D)+$/; -// eslint-disable-next-line no-control-regex const RIGHT_REGEX = /^(\x1b\[C)+$/; -// eslint-disable-next-line no-control-regex const WIDE_CHAR_REGEX = /[\u3001-\u3015\u301C\u3040-\u30FF\u3131-\u314E\u3400-\u4DBF\u4e00-\u9FFF\uAC00-\uD7A3\uFF01-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B\uFF3C\uFF3D\uFF3F\uFF5B\uFF5D]/; const RELEASE_REGEX = /^R20([0-9]{2})(a|b)$/; -// eslint-disable-next-line no-control-regex const WARNING_SENTINAL_REGEX = /((?:\[\x08)|(?:\]\x08))/; const PROMPTS = { @@ -99,6 +113,11 @@ const PROMPTS = { BUSY_PROMPT: '' }; +const enum EncodedTokenDataConsts { + FOREGROUND_MASK = 0b00000000111111111000000000000000, + FOREGROUND_OFFSET = 15 +} + // A modification of the word boundary regex being used by VS Code when replacing completions. // The first part splits on numbers. The second/third parts split on quoted strings, ie. plot("Color"| // the fourth part splits on unquoted words (same as VS Code's original regex), @@ -112,6 +131,7 @@ type MatlabData = any; // eslint-disable-line @typescript-eslint/no-explicit-any * Represents command window. Is a pseudoterminal to be used as the input/output processor in a VS Code terminal. */ export default class CommandWindow implements vscode.Pseudoterminal { + private readonly _context: vscode.ExtensionContext; private readonly _writeEmitter: vscode.EventEmitter; private _initialized: boolean = false; @@ -146,9 +166,14 @@ export default class CommandWindow implements vscode.Pseudoterminal { private _currentInputPromptString?: string; + private _tokenizer?: vscodeTextmate.IGrammar; + private _tokenizerRegistry?: vscodeTextmate.Registry; + private readonly _eventHandlers: vscode.Disposable[] = [] - constructor (private readonly _mvm: MVM, private readonly _notifier: Notifier) { + constructor (private readonly _mvm: MVM, private readonly _notifier: Notifier, context: vscode.ExtensionContext) { + this._context = context; + this._eventHandlers.push( this._mvm.on(MVM.Events.output, this.addOutput.bind(this)), this._mvm.on(MVM.Events.clc, this.clear.bind(this)), @@ -168,6 +193,10 @@ export default class CommandWindow implements vscode.Pseudoterminal { this._updateHasSelectionContext(); } + initialize (): void { + void this._initalizeTokenizer(); + } + /** * Called when a terminal with this pseudoterminal is opened. * @@ -231,20 +260,94 @@ export default class CommandWindow implements vscode.Pseudoterminal { this._updateHasSelectionContext(); } - private _writeCurrentPromptLine (): void { - if (this._activeAnchorIndex === undefined) { - this._writeEmitter.fire(this._currentPromptLine) + private _setRGBColor (color?: string): void { + if (color === undefined) { + this._writeEmitter.fire(ACTION_KEYS.DEFAULT_FOREGROUND); } else { - const selectionStart = this._currentPrompt.length + Math.min(this._activeCursorIndex, this._activeAnchorIndex); - const selectionEnd = this._currentPrompt.length + Math.max(this._activeCursorIndex, this._activeAnchorIndex); - const preSelection = this._currentPromptLine.slice(0, selectionStart); - const selection = this._currentPromptLine.slice(selectionStart, selectionEnd); - const postSelection = this._currentPromptLine.slice(selectionEnd); - this._writeEmitter.fire(preSelection); - this._writeEmitter.fire(ACTION_KEYS.INVERT_COLORS); - this._writeEmitter.fire(selection); - this._writeEmitter.fire(ACTION_KEYS.RESTORE_COLORS); - this._writeEmitter.fire(postSelection); + this._writeEmitter.fire(ACTION_KEYS.RGB_FOREGROUND(color)); + } + } + + private _writeCurrentPromptLine (): void { + const selectionStart = this._activeAnchorIndex === undefined ? Infinity : Math.min(this._activeCursorIndex, this._activeAnchorIndex); + const selectionEnd = this._activeAnchorIndex === undefined ? Infinity : Math.max(this._activeCursorIndex, this._activeAnchorIndex); + + this._writeEmitter.fire(ACTION_KEYS.ALL_DEFAULT_COLORS); + + const line = this._stripCurrentPrompt(this._currentPromptLine); + if ((this._tokenizer == null) || line === '') { + this._writeEmitter.fire(this._currentPromptLine); + return; + } + + this._writeEmitter.fire(this._currentPrompt); + + // Tokenize the prompt line. The tokenizeLine2 call returns the result in binary format containing, + // among other data, the start and end indices and foreground color to use based on the current theme + const lineTokens = this._tokenizer.tokenizeLine2(this._stripCurrentPrompt(this._currentPromptLine), vscodeTextmate.INITIAL); + let selectionInProgress = false; + + // Iterate over each token and display it + for (let j = 0; j < lineTokens.tokens.length / 2; j++) { + // Unpack the current token data + const tokenStart = lineTokens.tokens[2 * j]; + const tokenEnd = lineTokens.tokens[2 * j + 2] ?? line.length; + const tokenMetaData = lineTokens.tokens[2 * j + 1]; + const foregroundId = ((tokenMetaData & EncodedTokenDataConsts.FOREGROUND_MASK) >>> EncodedTokenDataConsts.FOREGROUND_OFFSET); + const color = this._tokenizerRegistry?.getColorMap()[foregroundId]; + + // If the current selection is entirely within the current token + if (selectionStart >= tokenStart && selectionStart < tokenEnd && selectionEnd < tokenEnd) { + const preSelection = line.substring(tokenStart, selectionStart); + const selection = line.substring(selectionStart, selectionEnd); + const postSelection = line.substring(selectionEnd, tokenEnd); + this._setRGBColor(color); + this._writeEmitter.fire(preSelection); + this._setRGBColor(undefined); + this._writeEmitter.fire(ACTION_KEYS.INVERT_COLORS); + this._writeEmitter.fire(selection); + this._writeEmitter.fire(ACTION_KEYS.RESTORE_COLORS); + this._setRGBColor(color); + this._writeEmitter.fire(postSelection); + + // If the current selection contains the start of the current token + } else if (selectionStart >= tokenStart && selectionStart < tokenEnd) { + const preSelection = line.substring(tokenStart, selectionStart); + const selection = line.substring(selectionStart, tokenEnd); + this._setRGBColor(color); + this._writeEmitter.fire(preSelection); + this._setRGBColor(undefined); + this._writeEmitter.fire(ACTION_KEYS.INVERT_COLORS); + this._writeEmitter.fire(selection); + selectionInProgress = true; + + // If the current selection contains the end of the current token + } else if (selectionEnd >= tokenStart && selectionEnd < tokenEnd) { + const selection = line.substring(tokenStart, selectionEnd); + const postSelection = line.substring(selectionEnd, tokenEnd); + this._setRGBColor(undefined); + this._writeEmitter.fire(ACTION_KEYS.INVERT_COLORS); + this._writeEmitter.fire(selection); + this._writeEmitter.fire(ACTION_KEYS.RESTORE_COLORS); + this._setRGBColor(color); + this._writeEmitter.fire(postSelection); + selectionInProgress = false; + + // If the current selection does not contain the start or end of the current selection at all. + } else { + const tokenStr = line.substring(tokenStart, tokenEnd); + if (selectionInProgress) { + this._writeEmitter.fire(ACTION_KEYS.INVERT_COLORS); + } else { + this._setRGBColor(color); + } + this._writeEmitter.fire(tokenStr); + if (selectionInProgress) { + this._writeEmitter.fire(ACTION_KEYS.RESTORE_COLORS); + } + } + + this._writeEmitter.fire(ACTION_KEYS.ALL_DEFAULT_COLORS); } } @@ -821,9 +924,9 @@ export default class CommandWindow implements vscode.Pseudoterminal { } const match = release.match(RELEASE_REGEX); if (match?.[1] !== undefined && Number.parseInt(match[1]) < 25) { - void this._mvm.eval(`try; if usejava('jvm'); com.mathworks.mde.cmdwin.CmdWinMLIF.setCWSize(${this._terminalDimensions.rows}, ${this._terminalDimensions.columns}); end; end;`); + void this._mvm.eval(`try; if usejava('jvm'); com.mathworks.mde.cmdwin.CmdWinMLIF.setCWSize(${this._terminalDimensions.rows}, ${this._terminalDimensions.columns}); end; end;`, false); } else { - void this._mvm.eval(`settings_vscode__ = settings; settings_vscode__.matlab.commandwindow.WindowSize.TemporaryValue = [${this._terminalDimensions.columns}, ${this._terminalDimensions.rows}]; clear settings_vscode__;`); + void this._mvm.eval(`settings_vscode__ = settings; settings_vscode__.matlab.commandwindow.WindowSize.TemporaryValue = [${this._terminalDimensions.columns}, ${this._terminalDimensions.rows}]; clear settings_vscode__;`, false); } this._lastSentTerminalDimensions = this._terminalDimensions; } @@ -1094,9 +1197,108 @@ export default class CommandWindow implements vscode.Pseudoterminal { return WIDE_CHAR_REGEX.test(char); } + /** + * Initialize the textmate tokenizer. This is the same as what the VS Code Editor uses and should guarentee identical results. + */ + private async _initalizeTokenizer (): Promise { + // Load the oniguruma regex library used by the tokenizer. + const wasmBin = fs.readFileSync(this._context.asAbsolutePath('node_modules/vscode-oniguruma/release/onig.wasm')).buffer; + + const vscodeOnigurumaLib = oniguruma.loadWASM(wasmBin as any).then(() => { + return { + createOnigScanner (patterns: any) { return new oniguruma.OnigScanner(patterns); }, + createOnigString (s: any) { return new oniguruma.OnigString(s); } + }; + }); + + const themeName = vscode.workspace.getConfiguration('workbench').get('colorTheme') as string; + + // Create a registry that can create a grammar from a scope name, providing it the MATLAB syntax file and the current theme. + const registry = new vscodeTextmate.Registry({ + theme: this._processTheme(themeName), + onigLib: vscodeOnigurumaLib, + loadGrammar: async () => { + const languageFile = await fsPromise.readFile(this._context.asAbsolutePath('syntaxes/Matlab.tmbundle/Syntaxes/MATLAB.tmLanguage')); + return vscodeTextmate.parseRawGrammar(languageFile.toString()); + } + }); + + // Handle theme changes by reprocessing the new theme and + vscode.window.onDidChangeActiveColorTheme(() => { + const themeName = vscode.workspace.getConfiguration('workbench').get('colorTheme') as string; + registry.setTheme(this._processTheme(themeName)); + }); + this._tokenizerRegistry = registry; + + // Load the MATLAB grammar + this._tokenizer = await registry.loadGrammar('source.matlab') ?? undefined; + } + + /** + * Convert the current VS Code theme into a form that can be used by the textmate tokenizer. + */ + private _processTheme (themeName: string): vscodeTextmate.IRawTheme { + const theme: vscodeTextmate.IRawTheme = { + name: themeName, + settings: [] + }; + + // Find the given theme extension. + let currentThemePath; + for (const extension of vscode.extensions.all) { + const themes = extension.packageJSON.contributes?.themes; + const currentTheme = themes?.find((theme: any) => theme.label === themeName || theme.id === themeName); + if (currentTheme) { + currentThemePath = path.join(extension.extensionPath, currentTheme.path); + break; + } + } + + // Load the theme and all recursively included themes. + const themePaths = []; + const themeFiles = []; + if (currentThemePath) { + themePaths.push(currentThemePath); + } + while (themePaths.length > 0) { + const themePath: string = themePaths.pop()!; + + let themeData: any; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + themeData = require(themePath); + } catch (err) { + console.log('Error while loading and parsing theme file: ', themePath, err); + // Return the still empty theme in the case of an error. + return theme; + } + + if (themeData !== undefined) { + themeFiles.push(themeData); + if (themeData.include) { + themePaths.push(path.join(path.dirname(themePath), themeData.include)); + } + } + } + + // Create the data structure that maps from textmate paths to colors. + while (themeFiles.length > 0) { + const themeData = themeFiles.pop(); + if (themeData.tokenColors) { + theme.settings.push(...themeData.tokenColors); + } + } + + // Manually add parentheses, brackets, and braces to the theme, because this is usually handled by bracket matching, not syntax highlighting. + theme.settings.push({ scope: ['punctuation.section.parens', 'punctuation.section.brackets', 'punctuation.section.braces'], settings: { foreground: '#FFD703' } }); + + return theme; + } + dispose (): void { this._eventHandlers.forEach(eventHandler => eventHandler.dispose()); this._writeEmitter.dispose(); + this._tokenizerRegistry?.dispose(); } onDidWrite: vscode.Event; diff --git a/src/commandwindow/TerminalService.ts b/src/commandwindow/TerminalService.ts index 45516b0..6d5903c 100644 --- a/src/commandwindow/TerminalService.ts +++ b/src/commandwindow/TerminalService.ts @@ -20,11 +20,15 @@ export default class TerminalService extends BaseService { private _currentMatlabTerminal?: vscode.Terminal; private _terminalCreationPromise?: ResolvablePromise; private _timeout: NodeJS.Timeout | undefined; + private readonly _client: Notifier; - constructor (private readonly _client: Notifier, mvm: MVM) { + constructor (readonly client: Notifier, mvm: MVM, context: vscode.ExtensionContext) { super(); - this._commandWindow = new CommandWindow(mvm, _client); + this._client = client; + + this._commandWindow = new CommandWindow(mvm, this._client, context); + this._commandWindow.initialize(); this._terminalOptions = { name: 'MATLAB', diff --git a/src/extension.ts b/src/extension.ts index 598a1c2..0a39668 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,6 +23,7 @@ import * as LicensingUtils from './utils/LicensingUtils' import BaseService from './services/BaseService' import WorkspaceBrowserProvider from './workspacebrowser/WorkspaceBrowserProvider' import MatlabProjectService from './services/projects/MatlabProjectService' +import MatlabTestService from './services/testing/MatlabTestService' const CONNECTION_STATUS_COMMAND = 'matlab.changeMatlabConnection' const OPEN_SETTINGS_COMMAND = 'workbench.action.openSettings' @@ -82,7 +83,7 @@ class MatlabExtension extends BaseService { // Initialize MVM, Terminal, and Debugger const multiclientNotifier = new MultiClientNotifier(this.client) this.mvm = new MVM(multiclientNotifier) - this.terminalService = new TerminalService(multiclientNotifier, this.mvm) + this.terminalService = new TerminalService(multiclientNotifier, this.mvm, context) this.executionCommandProvider = new ExecutionCommandProvider(this.mvm, this.terminalService, this.telemetryLogger) this.matlabDebugger = new MatlabDebugger(this.mvm, multiclientNotifier, this.telemetryLogger, this.terminalService) @@ -99,6 +100,9 @@ class MatlabExtension extends BaseService { // Initialize MATLAB Project Service const matlabProjectService = new MatlabProjectService(this.client, this.mvm, this.telemetryLogger) + // Initialize MATLAB Test Service + const matlabTestService = new MatlabTestService(this.client, this.mvm, this.telemetryLogger, context) + // Add all disposable services to context subscriptions this.own( this.telemetryLogger, @@ -111,7 +115,8 @@ class MatlabExtension extends BaseService { defaultEditorService, this.sectionModel, sectionStylingService, - matlabProjectService + matlabProjectService, + matlabTestService ) // =============== Setup UI Affordances =============== // @@ -132,7 +137,7 @@ class MatlabExtension extends BaseService { vscode.commands.registerCommand('matlab.runSection', async () => await this.executionCommandProvider.handleRunSection(this.sectionModel)), vscode.commands.registerCommand('matlab.runSelection', async () => await this.executionCommandProvider.handleRunSelection()), vscode.commands.registerCommand('matlab.interrupt', () => this.executionCommandProvider.handleInterrupt()), - vscode.commands.registerCommand('matlab.openCommandWindow', async () => await this.terminalService.openTerminalOrBringToFront()), + vscode.commands.registerCommand('matlab.openCommandWindow', this.handleOpenCommandWindow.bind(this)), vscode.commands.registerCommand('matlab.addFolderToPath', async (uri: vscode.Uri) => await this.executionCommandProvider.handleAddFolderToPath(uri)), vscode.commands.registerCommand('matlab.addFolderAndSubfoldersToPath', async (uri: vscode.Uri) => await this.executionCommandProvider.handleAddFolderAndSubfoldersToPath(uri)), vscode.commands.registerCommand('matlab.changeDirectory', async (uri: vscode.Uri) => await this.executionCommandProvider.handleChangeDirectory(uri)), @@ -169,7 +174,15 @@ class MatlabExtension extends BaseService { * Starts the langauge client */ async start (): Promise { - await this.client.start() + if (vscode.workspace.isTrusted) { + await this.client.start() + } else { + this.showUntrustedWorkspaceError() + + this.own(vscode.workspace.onDidGrantWorkspaceTrust(async () => { + await this.client.start() + })) + } } getConnectionStatusBarItem (): vscode.StatusBarItem { @@ -186,6 +199,22 @@ class MatlabExtension extends BaseService { }) } + /** + * Shows an error message to indicate that the language server cannot be started + * from untrusted workspaces. + */ + private showUntrustedWorkspaceError (): void { + const manageAction = 'Manage Workspace Trust' + void vscode.window.showErrorMessage( + 'MATLAB language server cannot run in an untrusted workspace.', + manageAction + ).then(choice => { + if (choice === manageAction) { + void vscode.commands.executeCommand('workbench.trust.manage') + } + }) + } + /** * Sets up the connection to the MATLAB language server. * Does not start the language client. @@ -246,6 +275,11 @@ class MatlabExtension extends BaseService { * Handles user input about whether to connect or disconnect from MATLAB® */ private handleChangeMatlabConnection (): void { + if (!vscode.workspace.isTrusted) { + this.showUntrustedWorkspaceError() + return + } + const connect = 'Connect to MATLAB' const disconnect = 'Disconnect from MATLAB' const options = [connect, disconnect] @@ -285,6 +319,19 @@ class MatlabExtension extends BaseService { }) } + /** + * Handler for the `matlab.openCommandWindow` command. + * Verifies the workspace is trusted before opening the command window. + */ + private async handleOpenCommandWindow (): Promise { + if (!vscode.workspace.isTrusted) { + this.showUntrustedWorkspaceError() + return + } + + await this.terminalService.openTerminalOrBringToFront() + } + /** * Event handler called when the VS Code configuration is changed by the user */ diff --git a/src/notifications/Notifications.ts b/src/notifications/Notifications.ts index 0b60b80..9c71006 100644 --- a/src/notifications/Notifications.ts +++ b/src/notifications/Notifications.ts @@ -63,7 +63,13 @@ enum Notification { // MATLAB projects ProjectOpened = 'matlab/project/opened', - ProjectClosed = 'matlab/project/closed' + ProjectClosed = 'matlab/project/closed', + + // Testing + TestRunRequest = 'matlab/testing/run/request', + TestRunEvent = 'matlab/testing/run/event', + TestRunOutput = 'matlab/testing/run/output', + TestRunComplete = 'matlab/testing/run/complete' } export default Notification diff --git a/src/services/telemetry/TelemetryLogger.ts b/src/services/telemetry/TelemetryLogger.ts index a84fa0b..56c7146 100644 --- a/src/services/telemetry/TelemetryLogger.ts +++ b/src/services/telemetry/TelemetryLogger.ts @@ -9,7 +9,8 @@ const PRODUCT = 'ML_VS_CODE' const APPLICATION_NAME = 'MATLAB_EXTENSION_FOR_VSCODE' const APPLICATION_KEY = 'OWY3N2FkZTMtYWU1My00MjU3LThjZTktMzFmMTAyYjM0Njc5' -const ENDPOINT = 'https://udc-service.mathworks.com/udc/service/v1/events' +const ENDPOINT = 'https://udc-service-integ3.mathworks.com/udc/service/v1/events' +// const ENDPOINT = 'https://udc-service.mathworks.com/udc/service/v1/events' export interface TelemetryEvent { eventKey: string @@ -35,7 +36,9 @@ export default class TelemetryLogger extends BaseService { if (event.eventKey === 'ML_VS_CODE_SETTING_CHANGE') { // Do log when the `matlab.telemetry` setting changes - return (event.data as { setting_name: string }).setting_name === 'telemetry' + if ((event.data as { setting_name: string }).setting_name === 'telemetry') { + return true + } } // Otherwise, adhere to the `matlab.telemetry` setting diff --git a/src/services/testing/MatlabTestDiscovery.ts b/src/services/testing/MatlabTestDiscovery.ts new file mode 100644 index 0000000..8e8c56d --- /dev/null +++ b/src/services/testing/MatlabTestDiscovery.ts @@ -0,0 +1,381 @@ +// Copyright 2026 The MathWorks, Inc. + +import * as path from 'path' +import * as vscode from 'vscode' + +import BaseService from '../BaseService' +import { MatlabMVMConnectionState, MVM } from '../../commandwindow/MVM' +import TelemetryLogger from '../telemetry/TelemetryLogger' +import { MatlabTestInfo, TestDiscoveryRawResult } from './MatlabTestInterfaces' + +const DISCOVERY_DEBOUNCE_MS = 500 +const WORKSPACE_STATE_KEY = 'matlab.testing.folders' +const WORKSPACE_STATE_FILES_KEY = 'matlab.testing.files' +const WORKSPACE_STATE_EXCLUDED_KEY = 'matlab.testing.excludedFiles' + +const STATUS_ITEM_ID = 'matlab-status-placeholder' + +export default class MatlabTestDiscovery extends BaseService { + private readonly testFolders: Set = new Set() + private readonly testFiles: Set = new Set() + private readonly excludedFiles: Set = new Set() + private debounceTimer: NodeJS.Timeout | undefined + private isDiscovering = false + private rediscoveryRequested = false + private fileWatchers: vscode.Disposable[] = [] + + constructor ( + private readonly controller: vscode.TestController, + private readonly mvm: MVM, + private readonly context: vscode.ExtensionContext, + private readonly telemetryLogger: TelemetryLogger + ) { + super() + + this.restorePersistedState() + this.setupResolveHandler() + this.registerEventListeners() + } + + private restorePersistedState (): void { + this.loadWorkspaceState(WORKSPACE_STATE_KEY, this.testFolders) + this.loadWorkspaceState(WORKSPACE_STATE_FILES_KEY, this.testFiles) + this.loadWorkspaceState(WORKSPACE_STATE_EXCLUDED_KEY, this.excludedFiles) + + if (this.mvm.getMatlabState() !== MatlabMVMConnectionState.CONNECTED && this.hasTestSources()) { + this.showStatusPlaceholder('Connect to MATLAB to discover tests') + } + } + + private loadWorkspaceState (key: string, target: Set): void { + const values = this.context.workspaceState.get(key, []) + values.forEach(v => target.add(v)) + } + + private setupResolveHandler (): void { + this.controller.resolveHandler = async () => { + if (this.mvm.getMatlabState() === MatlabMVMConnectionState.CONNECTED) { + await this.discoverAll() + } + } + } + + private registerEventListeners (): void { + this.own(this.mvm.on(MVM.Events.stateChanged, (oldState: MatlabMVMConnectionState, newState: MatlabMVMConnectionState) => { + if (newState === MatlabMVMConnectionState.CONNECTED) { + this.removeStatusPlaceholder() + void this.discoverAll() + } else if (newState === MatlabMVMConnectionState.DISCONNECTED) { + this.controller.items.replace([]) + if (this.hasTestSources()) { + this.showStatusPlaceholder('Connect to MATLAB to discover tests') + } + } + })) + + this.refreshFileWatchers() + } + + private refreshFileWatchers (): void { + this.disposeFileWatchers() + + for (const folder of this.testFolders) { + this.addFileWatcher(new vscode.RelativePattern(vscode.Uri.file(folder), '**/*.m')) + } + + for (const file of this.testFiles) { + const dir = vscode.Uri.file(path.dirname(file)) + this.addFileWatcher(new vscode.RelativePattern(dir, path.basename(file))) + } + } + + private addFileWatcher (pattern: vscode.RelativePattern): void { + const watcher = vscode.workspace.createFileSystemWatcher(pattern) + this.fileWatchers.push( + watcher, + watcher.onDidCreate(() => this.debouncedRediscover()), + watcher.onDidDelete(() => this.debouncedRediscover()), + watcher.onDidChange(() => this.debouncedRediscover()) + ) + } + + private disposeFileWatchers (): void { + this.fileWatchers.forEach(d => d.dispose()) + this.fileWatchers = [] + } + + override dispose (): void { + this.disposeFileWatchers() + super.dispose() + } + + /** Discovers tests from all registered folders and files, rebuilding the test tree. */ + async discoverAll (): Promise { + if (this.mvm.getMatlabState() !== MatlabMVMConnectionState.CONNECTED) { + return + } + if (this.isDiscovering) { + this.rediscoveryRequested = true + return + } + + this.isDiscovering = true + try { + this.controller.items.replace([]) + if (this.hasTestSources()) { + this.showStatusPlaceholder('Discovering tests...') + } + + if (this.testFolders.size > 0) { + await this.discoverFromPaths([...this.testFolders], 'folder') + } + + if (this.testFiles.size > 0) { + await this.discoverFromPaths([...this.testFiles], 'file') + } + + this.removeStatusPlaceholder() + } finally { + this.isDiscovering = false + if (this.rediscoveryRequested) { + this.rediscoveryRequested = false + void this.discoverAll() + } + } + } + + /** Prompts the user to select folder(s) and discovers tests within them. */ + async addTestFolder (): Promise { + const selected = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: true, + title: 'Select test folder(s)' + }) + if (selected == null || selected.length === 0) return + + for (const uri of selected) { + this.testFolders.add(uri.fsPath) + for (const excluded of this.excludedFiles) { + if (excluded.startsWith(uri.fsPath)) { + this.excludedFiles.delete(excluded) + } + } + } + await this.context.workspaceState.update(WORKSPACE_STATE_KEY, [...this.testFolders]) + await this.context.workspaceState.update(WORKSPACE_STATE_EXCLUDED_KEY, [...this.excludedFiles]) + this.refreshFileWatchers() + + if (this.mvm.getMatlabState() === MatlabMVMConnectionState.CONNECTED) { + const paths = selected.map(u => u.fsPath) + await this.discoverFromPaths(paths, 'folder') + } + } + + /** Prompts the user to select .m file(s) and discovers tests within them. */ + async addTestFile (): Promise { + const selected = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + filters: { 'MATLAB Files': ['m'] }, + title: 'Select test file(s)' + }) + if (selected == null || selected.length === 0) return + + for (const uri of selected) { + this.testFiles.add(uri.fsPath) + this.excludedFiles.delete(uri.fsPath) + } + await this.context.workspaceState.update(WORKSPACE_STATE_FILES_KEY, [...this.testFiles]) + await this.context.workspaceState.update(WORKSPACE_STATE_EXCLUDED_KEY, [...this.excludedFiles]) + this.refreshFileWatchers() + + if (this.mvm.getMatlabState() === MatlabMVMConnectionState.CONNECTED) { + const paths = selected.map(u => u.fsPath) + await this.discoverFromPaths(paths, 'file') + } + } + + /** Removes a test item from the tree and excludes its file from future discovery. */ + async removeTestItem (target: vscode.TestItem): Promise { + if (target == null) return + + while (target.parent != null) { + target = target.parent + } + + const filePath = target.uri?.fsPath ?? target.id + this.excludedFiles.add(filePath) + this.testFiles.delete(filePath) + + this.controller.items.delete(target.id) + + await this.context.workspaceState.update(WORKSPACE_STATE_EXCLUDED_KEY, [...this.excludedFiles]) + await this.context.workspaceState.update(WORKSPACE_STATE_FILES_KEY, [...this.testFiles]) + this.refreshFileWatchers() + } + + private async discoverFromPaths (paths: string[], mode: 'file' | 'folder'): Promise { + try { + const mdaPaths = { mwtype: 'string', mwsize: [1, paths.length], mwdata: paths } + + const response = await this.mvm.feval( + 'matlabls.handlers.testing.discoverTests', 1, [mdaPaths, mode] + ) + + if ('error' in response) { + const error = (response as { error: Record }).error + const errMsg = typeof error?.msg === 'string' ? error.msg : '' + if (errMsg !== '') { + void vscode.window.showWarningMessage(`Test discovery error: ${errMsg}`) + } + return + } + + const discoveryResult = (response as { result: TestDiscoveryRawResult[] }).result?.[0] + + if (discoveryResult == null) { + void vscode.window.showWarningMessage('Test discovery: unexpected empty response from MATLAB') + return + } + + if (discoveryResult.error != null && discoveryResult.error !== '') { + void vscode.window.showWarningMessage(`Test discovery: ${discoveryResult.error}`) + return + } + + if (discoveryResult.warning != null && discoveryResult.warning !== '') { + void vscode.window.showWarningMessage(`Test discovery: ${discoveryResult.warning}`) + } + + const tests = this.parseRawResult(discoveryResult) + if (tests.length === 0) { + void vscode.window.showInformationMessage('No MATLAB tests found in the selected location.') + return + } + + this.buildTestTree(tests) + this.telemetryLogger.logEvent({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'testing.discover', test_count: tests.length } + }) + } catch { + // MATLAB disconnected or feval rejected — leave tree as-is + } + } + + private parseRawResult (raw: TestDiscoveryRawResult): MatlabTestInfo[] { + const tests: MatlabTestInfo[] = [] + const names = this.unwrapCellArray(raw.names) + const filenames = this.unwrapCellArray(raw.filenames) + const procedureNames = this.unwrapCellArray(raw.procedureNames) + const testParentNames = this.unwrapCellArray(raw.testParentNames) + const parameterizations = this.unwrapCellArray(raw.parameterizations) + + for (let i = 0; i < names.length; i++) { + tests.push({ + name: names[i], + filename: filenames[i], + procedureName: procedureNames[i], + testParentName: testParentNames[i], + parameterization: parameterizations[i] + }) + } + return tests + } + + private unwrapCellArray (value: unknown): string[] { + if (Array.isArray(value)) { + return value as string[] + } + if (value != null && typeof value === 'object' && 'mwdata' in value) { + return (value as { mwdata: string[] }).mwdata + } + return [] + } + + private buildTestTree (tests: MatlabTestInfo[]): void { + const filtered = tests.filter(t => !this.excludedFiles.has(t.filename)) + const groupedByFile = this.groupBy(filtered, t => t.filename) + + for (const [filename, fileTests] of groupedByFile) { + const fileItem = this.getOrCreateFileItem(filename) + this.populateFileItem(fileItem, fileTests) + } + } + + private getOrCreateFileItem (filename: string): vscode.TestItem { + let fileItem = this.controller.items.get(filename) + if (fileItem == null) { + const fileUri = vscode.Uri.file(filename) + fileItem = this.controller.createTestItem(filename, path.basename(filename), fileUri) + fileItem.range = new vscode.Range(0, 0, 0, 0) + this.controller.items.add(fileItem) + } + fileItem.children.replace([]) + return fileItem + } + + private populateFileItem (fileItem: vscode.TestItem, fileTests: MatlabTestInfo[]): void { + const fileUri = fileItem.uri! + const groupedByMethod = this.groupBy(fileTests, t => t.procedureName) + + for (const [methodName, methodTests] of groupedByMethod) { + if (methodTests.length === 1 && methodTests[0].parameterization === '') { + const methodItem = this.controller.createTestItem(methodTests[0].name, methodName, fileUri) + methodItem.range = new vscode.Range(0, 0, 0, 0) + fileItem.children.add(methodItem) + } else { + this.createParameterizedMethodItem(fileItem, fileUri, methodName, methodTests) + } + } + } + + private createParameterizedMethodItem (fileItem: vscode.TestItem, fileUri: vscode.Uri, methodName: string, methodTests: MatlabTestInfo[]): void { + const methodId = `${methodTests[0].testParentName}/${methodName}` + const methodItem = this.controller.createTestItem(methodId, methodName, fileUri) + methodItem.range = new vscode.Range(0, 0, 0, 0) + fileItem.children.add(methodItem) + + for (const test of methodTests) { + const paramLabel = test.parameterization !== '' ? test.parameterization : test.name + const paramItem = this.controller.createTestItem(test.name, paramLabel, fileUri) + paramItem.range = new vscode.Range(0, 0, 0, 0) + methodItem.children.add(paramItem) + } + } + + private groupBy (items: T[], keyFn: (item: T) => string): Map { + const map = new Map() + for (const item of items) { + const key = keyFn(item) + const existing = map.get(key) ?? [] + existing.push(item) + map.set(key, existing) + } + return map + } + + private hasTestSources (): boolean { + return this.testFolders.size > 0 || this.testFiles.size > 0 + } + + private showStatusPlaceholder (label: string): void { + if (this.controller.items.get(STATUS_ITEM_ID) != null) return + const item = this.controller.createTestItem(STATUS_ITEM_ID, label) + item.canResolveChildren = false + this.controller.items.add(item) + } + + private removeStatusPlaceholder (): void { + this.controller.items.delete(STATUS_ITEM_ID) + } + + private debouncedRediscover (): void { + if (this.debounceTimer != null) { + clearTimeout(this.debounceTimer) + } + this.debounceTimer = setTimeout(() => { void this.discoverAll() }, DISCOVERY_DEBOUNCE_MS) + } +} diff --git a/src/services/testing/MatlabTestInterfaces.ts b/src/services/testing/MatlabTestInterfaces.ts new file mode 100644 index 0000000..58da5f2 --- /dev/null +++ b/src/services/testing/MatlabTestInterfaces.ts @@ -0,0 +1,56 @@ +// Copyright 2026 The MathWorks, Inc. + +export interface MatlabTestInfo { + name: string + filename: string + procedureName: string + testParentName: string + parameterization: string +} + +export interface TestDiscoveryRawResult { + names: unknown + filenames: unknown + procedureNames: unknown + testParentNames: unknown + parameterizations: unknown + error: string + warning: string +} + +export interface TestRunEvent { + type: 'started' | 'finished' | 'complete' + testName: string + status?: 'passed' | 'failed' | 'incomplete' + duration?: number + diagnostics?: TestDiagnosticInfo[] +} + +export interface TestDiagnosticInfo { + message: string + failedOnLine: number + failedInFile: string + stack: StackFrame[] +} + +export interface StackFrame { + file: string + name: string + line: number +} + +export interface TestRunRequest { + runId: string + testFiles: string[] + testNames?: string[] +} + +export interface TestRunEventNotification { + runId: string + event: Record +} + +export interface TestRunCompleteNotification { + runId: string + error?: string +} diff --git a/src/services/testing/MatlabTestRunner.ts b/src/services/testing/MatlabTestRunner.ts new file mode 100644 index 0000000..556726a --- /dev/null +++ b/src/services/testing/MatlabTestRunner.ts @@ -0,0 +1,227 @@ +// Copyright 2026 The MathWorks, Inc. + +import * as vscode from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' + +import BaseService from '../BaseService' +import { MatlabMVMConnectionState, MVM } from '../../commandwindow/MVM' +import TelemetryLogger from '../telemetry/TelemetryLogger' +import Notification from '../../notifications/Notifications' +import { TestRunEventNotification, TestRunCompleteNotification } from './MatlabTestInterfaces' + +export default class MatlabTestRunner extends BaseService { + private readonly runProfile: vscode.TestRunProfile + private readonly activeRuns = new Map() + private readonly testItemsByName = new Map() + + constructor ( + private readonly controller: vscode.TestController, + private readonly client: LanguageClient, + private readonly mvm: MVM, + private readonly telemetryLogger: TelemetryLogger + ) { + super() + + this.runProfile = this.controller.createRunProfile( + 'Run Tests', + vscode.TestRunProfileKind.Run, + (request, token) => this.runTests(request, token) + ) + this.own(this.runProfile) + + this.own( + this.client.onNotification(Notification.TestRunEvent, (data: TestRunEventNotification) => { + this.handleTestRunEvent(data) + }), + this.client.onNotification(Notification.TestRunComplete, (data: TestRunCompleteNotification) => { + this.handleTestRunComplete(data) + }), + this.client.onNotification(Notification.TestRunOutput, (data: { runId: string, text: string }) => { + const run = this.activeRuns.get(data.runId) + if (run != null) { + run.appendOutput(data.text.replace(/\r?\n/g, '\r\n')) + } + }), + this.mvm.on(MVM.Events.stateChanged, (oldState: MatlabMVMConnectionState, newState: MatlabMVMConnectionState) => { + if (newState === MatlabMVMConnectionState.DISCONNECTED) { + this.endAllActiveRuns('MATLAB disconnected') + } + }) + ) + } + + public runAll (): void { + const request = new vscode.TestRunRequest() + void this.runTests(request, new vscode.CancellationTokenSource().token) + } + + private async runTests (request: vscode.TestRunRequest, token: vscode.CancellationToken): Promise { + if (this.mvm.getMatlabState() !== MatlabMVMConnectionState.CONNECTED) { + void vscode.window.showWarningMessage('Connect to MATLAB to run tests.') + return + } + + const run = this.controller.createTestRun(request) + const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + this.activeRuns.set(runId, run) + + const testItems = this.collectTestItems(request) + const testFiles = new Set() + const testNames: string[] = [] + const isSelectiveRun = request.include != null && request.include.length > 0 + + for (const item of testItems) { + run.enqueued(item) + + const filePath = item.uri?.fsPath ?? '' + this.testItemsByName.set(`${filePath}::${item.id}`, item) + + if (item.uri != null) { + testFiles.add(item.uri.fsPath) + } + + if (isSelectiveRun) { + testNames.push(item.id) + } + } + + token.onCancellationRequested(() => { + this.mvm.interrupt() + this.telemetryLogger.logEvent({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'testing.cancel' } + }) + }) + + this.telemetryLogger.logEvent({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'testing.run', test_count: testItems.length } + }) + + void this.client.sendNotification(Notification.TestRunRequest, { + runId, + testFiles: [...testFiles], + testNames: isSelectiveRun ? testNames : undefined + }) + } + + private handleTestRunEvent (data: TestRunEventNotification): void { + const run = this.activeRuns.get(data.runId) + if (run == null) return + + const event = data.event + const testName = event.testName as string + const rawTestFile = (event.testFile as string) ?? '' + const testFile = rawTestFile !== '' ? vscode.Uri.file(rawTestFile).fsPath : '' + const testItem = this.testItemsByName.get(`${testFile}::${testName}`) + if (testItem == null) return + + if (event.type === 'started') { + run.started(testItem) + } else if (event.type === 'finished') { + this.applyTestResult(run, testItem, event) + } + } + + private applyTestResult (run: vscode.TestRun, testItem: vscode.TestItem, event: Record): void { + const durationMs = event.duration != null ? (event.duration as number) * 1000 : undefined + + switch (event.status) { + case 'passed': + run.passed(testItem, durationMs) + break + case 'failed': { + const messages = this.buildTestMessages(event.diagnostics) + run.failed(testItem, messages, durationMs) + break + } + case 'incomplete': { + run.skipped(testItem) + break + } + default: + run.skipped(testItem) + } + } + + private buildTestMessages (diagnostics: unknown): vscode.TestMessage[] { + const diagArray = this.unwrapDiagnostics(diagnostics) + if (diagArray.length === 0) { + return [new vscode.TestMessage('Test failed')] + } + + return diagArray.map((diag: Record) => { + const msgText = (diag.message as string) ?? 'Test failed' + const message = new vscode.TestMessage(msgText) + const failedInFile = diag.failedInFile as string + const failedOnLine = diag.failedOnLine as number + if (failedInFile != null && failedInFile !== '' && failedOnLine > 0) { + message.location = new vscode.Location( + vscode.Uri.file(failedInFile), + new vscode.Position(failedOnLine - 1, 0) + ) + } + return message + }) + } + + private unwrapDiagnostics (diagnostics: unknown): Array> { + if (Array.isArray(diagnostics)) { + return diagnostics as Array> + } + if (diagnostics != null && typeof diagnostics === 'object' && 'mwdata' in diagnostics) { + const mwdata = (diagnostics as { mwdata: unknown }).mwdata + if (Array.isArray(mwdata)) { + return mwdata as Array> + } + } + return [] + } + + private handleTestRunComplete (data: TestRunCompleteNotification): void { + const run = this.activeRuns.get(data.runId) + if (run == null) return + + if (data.error != null) { + void vscode.window.showErrorMessage(`MATLAB test run failed: ${data.error}`) + } + + run.end() + this.activeRuns.delete(data.runId) + } + + private endAllActiveRuns (error: string): void { + for (const [runId, run] of this.activeRuns) { + void vscode.window.showErrorMessage(`MATLAB test run failed: ${error}`) + run.end() + this.activeRuns.delete(runId) + } + } + + private collectTestItems (request: vscode.TestRunRequest): vscode.TestItem[] { + const items: vscode.TestItem[] = [] + + if (request.include != null && request.include.length > 0) { + for (const item of request.include) { + this.collectLeafItems(item, items) + } + } else { + this.controller.items.forEach(item => { + this.collectLeafItems(item, items) + }) + } + + return items + } + + private collectLeafItems (item: vscode.TestItem, result: vscode.TestItem[]): void { + if (item.children.size === 0) { + result.push(item) + } else { + item.children.forEach(child => { + this.collectLeafItems(child, result) + }) + } + } +} diff --git a/src/services/testing/MatlabTestService.ts b/src/services/testing/MatlabTestService.ts new file mode 100644 index 0000000..6a47c7c --- /dev/null +++ b/src/services/testing/MatlabTestService.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The MathWorks, Inc. + +import * as vscode from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' + +import BaseService from '../BaseService' +import { MVM } from '../../commandwindow/MVM' +import TelemetryLogger from '../telemetry/TelemetryLogger' +import MatlabTestDiscovery from './MatlabTestDiscovery' +import MatlabTestRunner from './MatlabTestRunner' + +export default class MatlabTestService extends BaseService { + private readonly controller: vscode.TestController + private readonly discovery: MatlabTestDiscovery + private readonly runner: MatlabTestRunner + + constructor ( + private readonly client: LanguageClient, + private readonly mvm: MVM, + private readonly telemetryLogger: TelemetryLogger, + private readonly context: vscode.ExtensionContext + ) { + super() + + this.controller = vscode.tests.createTestController('matlab-tests', 'MATLAB Tests') + this.own(this.controller) + + void vscode.commands.executeCommand('setContext', 'matlab.testing.isActive', true) + + this.discovery = new MatlabTestDiscovery(this.controller, this.mvm, this.context, this.telemetryLogger) + this.own(this.discovery) + + this.runner = new MatlabTestRunner(this.controller, this.client, this.mvm, this.telemetryLogger) + this.own(this.runner) + + this.own( + vscode.commands.registerCommand('matlab.testing.runAllTests', () => this.handleRunAllTests()), + vscode.commands.registerCommand('matlab.testing.discoverTests', () => this.discovery.discoverAll()), + vscode.commands.registerCommand('matlab.testing.addTestFolder', () => this.discovery.addTestFolder()), + vscode.commands.registerCommand('matlab.testing.addTestFile', () => this.discovery.addTestFile()), + vscode.commands.registerCommand('matlab.testing.removeTestItem', (item: vscode.TestItem) => this.discovery.removeTestItem(item)) + ) + } + + private handleRunAllTests (): void { + this.runner.runAll() + } +} diff --git a/src/test/test-files/SampleTestClass.m b/src/test/test-files/SampleTestClass.m new file mode 100644 index 0000000..589f4dc --- /dev/null +++ b/src/test/test-files/SampleTestClass.m @@ -0,0 +1,15 @@ +classdef SampleTestClass < matlab.unittest.TestCase + methods (Test) + function testPassing(testCase) + testCase.verifyEqual(1+1, 2); + end + + function testFailing(testCase) + testCase.verifyEqual(1+1, 3); + end + + function testIncomplete(testCase) + testCase.assumeTrue(false); + end + end +end diff --git a/src/test/test-files/project-with-deps/matlab.toml b/src/test/test-files/project-with-deps/matlab.toml new file mode 100644 index 0000000..e45eb77 --- /dev/null +++ b/src/test/test-files/project-with-deps/matlab.toml @@ -0,0 +1,4 @@ +name = "My Project" + +[dependencies] +myPkg1 = "*" diff --git a/src/test/test-files/project-with-deps/repoFolder/myPkg1/resources/mpackage.json b/src/test/test-files/project-with-deps/repoFolder/myPkg1/resources/mpackage.json new file mode 100644 index 0000000..7291e86 --- /dev/null +++ b/src/test/test-files/project-with-deps/repoFolder/myPkg1/resources/mpackage.json @@ -0,0 +1,28 @@ +{ + "name": "myPkg1", + "version": "1.0.0", + "id": "adc86eb1-3725-468b-9bac-e2f6f46dd609", + "formerNames": [], + "displayName": "myPkg1", + "summary": "", + "tags": [], + "readme": "", + "provider": { + "name": "", + "organization": "", + "email": "", + "url": "" + }, + "folders": [], + "dependencies": [], + "releaseCompatibility": "", + "supportedPlatforms": [ + { + "platform": "any", + "architectures": [ + "any" + ] + } + ], + "schemaVersion": "1.2.0" +} diff --git a/src/test/tools/tester/TerminalTester.ts b/src/test/tools/tester/TerminalTester.ts index bbb4944..d5e3832 100644 --- a/src/test/tools/tester/TerminalTester.ts +++ b/src/test/tools/tester/TerminalTester.ts @@ -74,8 +74,127 @@ export class TerminalTester { return content.includes(expected) } + /** + * Assert that the terminal contains yellow-colored text matching the expected string. + */ + public async assertTextIsYellow (expected: string, message: string): Promise { + return await this.vs.poll(this.hasTextColor.bind(this, expected, 'yellow'), true, `Assertion on terminal color: ${message}`) + } + + /** + * Assert that the terminal contains text in the default (white) color. + */ + public async assertTextIsWhite (expected: string, message: string): Promise { + return await this.vs.poll(this.hasTextColor.bind(this, expected, 'white'), true, `Assertion on terminal color: ${message}`) + } + + /** + * Assert that the terminal contains text with inline color styling (from syntax highlighting). + */ + public async assertTextHasColor (expected: string, message: string): Promise { + return await this.vs.poll(this.hasInlineColor.bind(this, expected), true, `Assertion on terminal color: ${message}`) + } + + /** + * Assert that the terminal text matching expected has uniform color (no distinct syntax highlighting). + * When text is selected, xterm applies a single selection foreground color to all spans, + * replacing the per-token syntax colors. + */ + public async assertTextHasUniformColor (expected: string, message: string): Promise { + return await this.vs.poll(this.hasUniformColor.bind(this, expected), true, `Assertion on terminal color: ${message}`) + } + + private async getStylesForText (expected: string): Promise { + try { + const rows = await this.terminal.findElements(vet.By.css('.xterm-rows span')) + const styles: string[] = [] + for (const row of rows) { + const text = await row.getText() + if (text.includes(expected)) { + styles.push(await row.getAttribute('style') ?? '') + } + } + return styles + } catch { + return null + } + } + + private async hasUniformColor (expected: string): Promise { + const styles = await this.getStylesForText(expected) + if (styles == null || styles.length === 0) return false + return new Set(styles).size === 1 + } + + private async hasInlineColor (expected: string): Promise { + const styles = await this.getStylesForText(expected) + if (styles == null) return false + return styles.some(style => style.includes('color:')) + } + + private static readonly COLOR_SELECTORS: Record = { + yellow: '.xterm-fg-3, .xterm-fg-11' + } + + private async hasTextColor (expected: string, color: string): Promise { + if (color === 'white') { + return await this.isTextDefaultColor(expected) + } + const selector = TerminalTester.COLOR_SELECTORS[color] + const elements = await this.terminal.findElements(vet.By.css(selector)) + for (const el of elements) { + if ((await el.getText()).includes(expected)) { + return true + } + } + return false + } + + // White (default) means: text exists in the terminal DOM but has no xterm-fg-* color class + private async isTextDefaultColor (expected: string): Promise { + const rows = await this.terminal.findElements(vet.By.css('.xterm-rows span')) + for (const row of rows) { + const text = await row.getText() + if (text.includes(expected)) { + const classAttr = await row.getAttribute('class') + if (classAttr?.match(/xterm-fg-\d+/) == null) { + return true + } + } + } + return false + } + public async type (text: string): Promise { const container = await this.terminal.findElement(vet.By.className('xterm-helper-textarea')); return await container.sendKeys(text) } + + public async closeTerminal (): Promise { + await this.vs.executeCommand('workbench.action.togglePanel') + await this.assertMATLABTerminalNotVisible() + } + + public async assertMATLABTerminalNotVisible (timeout = 5000): Promise { + await this.vs.poll( + async () => !(await this.isPanelDisplayed()), + true, + 'Expected terminal panel to not be visible', + timeout + ) + } + + public async assertMATLABTerminalVisible (timeout = 30000): Promise { + await this.vs.poll( + async () => await this.isPanelDisplayed(), + true, + 'Expected terminal panel to be visible', + timeout + ) + } + + private async isPanelDisplayed (): Promise { + const panel = new vet.BottomBarPanel() + return await panel.isDisplayed().catch(() => false) + } } diff --git a/src/test/tools/tester/TestExplorerTester.ts b/src/test/tools/tester/TestExplorerTester.ts new file mode 100644 index 0000000..5c9bb58 --- /dev/null +++ b/src/test/tools/tester/TestExplorerTester.ts @@ -0,0 +1,66 @@ +// Copyright 2026 The MathWorks, Inc. +import * as assert from 'assert' +import * as vet from 'vscode-extension-tester' +import { VSCodeTester } from './VSCodeTester' + +export class TestExplorerTester { + private readonly vs: VSCodeTester + + public constructor (vs: VSCodeTester) { + this.vs = vs + } + + public async openTestExplorer (): Promise { + const activityBar = new vet.ActivityBar() + const testingControl = await activityBar.getViewControl('Testing') + assert.ok(testingControl, 'Testing activity bar icon should be present') + const title = await testingControl.getTitle() + assert.ok(title.startsWith('Testing'), `Expected Testing view control in activity bar, got: ${title}`) + await testingControl.openView() + } + + public async assertWelcomeContentVisible (): Promise { + const sideBar = new vet.SideBarView() + await this.vs.poll(async () => { + const elements = await sideBar.findElements(vet.By.xpath('//*[contains(text(), "Add Test Folder") or contains(text(), "Add Test File") or contains(text(), "MATLAB")]')) + return elements.length > 0 + }, true, 'Expected MATLAB Test Explorer content in Testing view', 10000) + } + + public async assertCommandsInPalette (expectedCommands: string[]): Promise { + const prompt = await this.vs.workbench.openCommandPrompt() as vet.InputBox + await prompt.setText('>MATLAB: Add Test') + + let labels: string[] = [] + await this.vs.poll(async () => { + const picks = await prompt.getQuickPicks() + labels = await Promise.all(picks.map(p => p.getLabel())) + return labels.length > 0 + }, true, 'Expected quick picks to appear after filtering', 10000) + + await prompt.cancel() + + for (const command of expectedCommands) { + this.assertCommandPresentOnce(labels, command) + } + } + + private assertCommandPresentOnce (labels: string[], command: string): void { + const matches = labels.filter(label => label.includes(command)) + assert.strictEqual(matches.length, 1, `Expected one "${command}" command`) + } + + public async assertNoErrorNotifications (): Promise { + const workbench = new vet.Workbench() + const notifications = await workbench.getNotifications() + for (const notification of notifications) { + const type = await notification.getType() + const message = await notification.getMessage() + assert.notStrictEqual( + type, + vet.NotificationType.Error, + `Unexpected error notification: ${message}` + ) + } + } +} diff --git a/src/test/tools/tester/TestSuite.ts b/src/test/tools/tester/TestSuite.ts index 02600f0..d928dd1 100644 --- a/src/test/tools/tester/TestSuite.ts +++ b/src/test/tools/tester/TestSuite.ts @@ -30,12 +30,15 @@ export class TestSuite { 'MATLAB.telemetry': false, 'MATLAB.startDebuggerAutomatically': true, 'window.dialogStyle': 'custom', + 'window.titleBarStyle': 'custom', 'terminal.integrated.copyOnSelection': true, + 'terminal.integrated.gpuAcceleration': 'off', // Forces xterm to use DOM renderer so terminal color classes are queryable by tests 'debug.toolBarLocation': 'docked', 'workbench.startupEditor': 'none', 'terminal.integrated.sendKeybindingsToShell': true, 'editor.action.toggleTabFocusMode': false }) + fs.writeFileSync(settingsjson, settings) this.vscodeSettings = settingsjson diff --git a/src/test/tools/tester/VSCodeTester.ts b/src/test/tools/tester/VSCodeTester.ts index 9b3e384..0cea5a7 100644 --- a/src/test/tools/tester/VSCodeTester.ts +++ b/src/test/tools/tester/VSCodeTester.ts @@ -240,6 +240,32 @@ export class VSCodeTester { return contextMenu! } + /** + * Right-click a specific file in the Explorer sidebar and return the context menu + */ + public async openFileContextMenu (filename: string): Promise { + const activityBar = new vet.ActivityBar() + const explorerControl = await activityBar.getViewControl('Explorer') + const view = await explorerControl?.openView() as vet.SideBarView + + let contextMenu: vet.ContextMenu | null = null + await this.poll(async () => { + try { + const content = view.getContent() + const sections = await content.getSections() + const section = sections[0] + await section.expand() + const item = await section.findItem(filename) as vet.ViewItem + if (item == null) return false + contextMenu = await item.openContextMenu() + return true + } catch (e) { + return false + } + }, true, `Expected to find and right-click ${filename} in Explorer`) + return contextMenu! + } + /** * Type text into an open input box and confirm with Enter */ diff --git a/src/test/ui/project.test.ts b/src/test/ui/project.test.ts index 9ac33bf..7b8c804 100644 --- a/src/test/ui/project.test.ts +++ b/src/test/ui/project.test.ts @@ -1,6 +1,7 @@ // Copyright 2026 The MathWorks, Inc. import { VSCodeTester } from '../tools/tester/VSCodeTester' -import { before, afterEach, after } from 'mocha'; +import { Key } from 'vscode-extension-tester' +import { before, after } from 'mocha'; import * as fs from 'fs'; import * as path from 'path'; @@ -8,25 +9,21 @@ suite('Project UI Tests', () => { let vs: VSCodeTester const workspaceFolder = path.resolve(__dirname, '..', '..', '..', '.s') + function cleanProjectFiles (): void { + const tomlFile = path.join(workspaceFolder, 'matlab.toml') + if (fs.existsSync(tomlFile)) fs.unlinkSync(tomlFile) + const repoFolder = path.join(workspaceFolder, 'repoFolder') + if (fs.existsSync(repoFolder)) fs.rmSync(repoFolder, { recursive: true }) + } + before(async () => { vs = new VSCodeTester(); + cleanProjectFiles() await vs.openEditor('hScript1.m') await vs.assertMATLABConnected() await vs.closeActiveEditor() }); - afterEach(async () => { - // Delete project - const projFile = path.join(workspaceFolder, 'TestProject.prj') - if (fs.existsSync(projFile)) { - fs.unlinkSync(projFile) - } - const resourcesDir = path.join(workspaceFolder, 'resources') - if (fs.existsSync(resourcesDir)) { - fs.rmSync(resourcesDir, { recursive: true }) - } - }); - after(async () => { await vs.disconnectFromMATLAB() }); @@ -42,5 +39,61 @@ suite('Project UI Tests', () => { // Close matlab project and verify status bar await vs.executeCommand('MATLAB: Close Project') await vs.assertStatusBarItemNotContains('MATLAB project', 'Expected MATLAB project status bar item to not be visible after closing project') + + // Cleanup + const projFile = path.join(workspaceFolder, 'TestProject.prj') + if (fs.existsSync(projFile)) fs.unlinkSync(projFile) + const resourcesDir = path.join(workspaceFolder, 'resources') + if (fs.existsSync(resourcesDir)) fs.rmSync(resourcesDir, { recursive: true }) + }); + + test('Test prompt when opening project with dependencies', async function () { + await vs.openMATLABTerminal() + + if (await vs.isMatlabVersionLessThan('R2026b')) { + this.skip() + } + + // Copy fixture files into workspace + const fixtureSource = path.resolve(__dirname, '..', 'test-files', 'project-with-deps') + fs.copyFileSync( + path.join(fixtureSource, 'matlab.toml'), + path.join(workspaceFolder, 'matlab.toml') + ) + fs.cpSync( + path.join(fixtureSource, 'repoFolder'), + path.join(workspaceFolder, 'repoFolder'), + { recursive: true } + ) + + // Register the package repository and ensure myPkg1 is not installed + const repoPath = path.join(workspaceFolder, 'repoFolder').replace(/\\/g, '/') + await vs.terminal.executeCommand('try,mpmRemoveRepository("myRepo");end') + await vs.terminal.executeCommand(`mpmAddRepository("myRepo","${repoPath}")`) + await vs.terminal.executeCommand('try,mpmuninstall("myPkg1",Prompt=false,Force=true);end') + + // Hide terminal panel so we can verify the extension brings it forward + await vs.terminal.closeTerminal() + + // Right-click matlab.toml and open project + const menu = await vs.openFileContextMenu('matlab.toml') + await menu.select('MATLAB: Project', 'MATLAB: Open Project...') + + // The extension should automatically bring the terminal forward with the prompt + await vs.terminal.assertMATLABTerminalVisible() + await vs.terminal.assertContains('Do you want to continue? [YES/no]:', 'Expected terminal to show dependency prompt') + + // Accept dependencies and verify the project opens + await vs.terminal.type('YES') + await vs.terminal.type(Key.ENTER) + await vs.assertStatusBarItemContains('MATLAB project', 'Expected project to open after installing dependencies', 60000) + + // Close project and verify + await vs.executeCommand('MATLAB: Close Project') + await vs.assertStatusBarItemNotContains('MATLAB project', 'Expected project status bar item to disappear after closing') + + // Cleanup + await vs.terminal.executeCommand('try,mpmuninstall("myPkg1",Prompt=false,Force=true);end') + cleanProjectFiles() }) }); diff --git a/src/test/ui/terminal.test.ts b/src/test/ui/terminal.test.ts index 6bfeb58..f665ede 100644 --- a/src/test/ui/terminal.test.ts +++ b/src/test/ui/terminal.test.ts @@ -5,6 +5,7 @@ import { Key } from 'selenium-webdriver'; suite('Terminal UI Tests', () => { let vs: VSCodeTester + let skipWarningColorTests: boolean before(async () => { vs = new VSCodeTester(); @@ -12,6 +13,7 @@ suite('Terminal UI Tests', () => { await vs.assertMATLABConnected() await vs.openMATLABTerminal() await vs.closeActiveEditor() + skipWarningColorTests = await vs.isMatlabVersionLessThan('R2025b') }); afterEach(async () => { @@ -101,6 +103,46 @@ suite('Terminal UI Tests', () => { await vs.terminal.assertContains('1 1 1 1 1 1', 'output should not be wrapped') }) + test('Test warning text appears yellow', async function () { + if (skipWarningColorTests) { + this.skip() + } + await vs.terminal.executeCommand("warning('test warning message')") + await vs.terminal.assertTextIsYellow('test warning message', 'warning output should be yellow') + }) + + test('Test normal output is white', async function () { + if (skipWarningColorTests) { + this.skip() + } + await vs.terminal.executeCommand("disp('normal text')") + await vs.terminal.assertTextIsWhite('normal text', 'normal output should be default color') + }) + + test('Test color resets after warning', async function () { + if (skipWarningColorTests) { + this.skip() + } + await vs.terminal.executeCommand("warning('yellow text')") + await vs.terminal.executeCommand("disp('after warning')") + await vs.terminal.assertTextIsYellow('yellow text', 'warning should be yellow') + await vs.terminal.assertTextIsWhite('after warning', 'text after warning should be default color') + }) + + test('Test syntax highlighting colors typed text', async () => { + await vs.terminal.type("x = 'hello'") + await vs.terminal.assertTextHasColor('hello', 'typed text should have syntax coloring') + await vs.terminal.type(Key.ESCAPE) + }) + + test('Test selected text is uncolored', async () => { + await vs.terminal.type("x = 'hello'") + await vs.terminal.assertTextHasColor('hello', 'typed text should have syntax coloring before selection') + await vs.terminal.type(Key.chord(Key.SHIFT, Key.HOME)) + await vs.terminal.assertTextHasUniformColor('hello', 'selected text should have uniform color, not syntax highlighting') + await vs.terminal.type(Key.ESCAPE) + }) + test('Test prompt string from input command', async () => { await vs.terminal.executeCommand('prompt = "Input a color :";') await vs.terminal.executeCommand('clc') diff --git a/src/test/ui/testExplorer.test.ts b/src/test/ui/testExplorer.test.ts new file mode 100644 index 0000000..2268607 --- /dev/null +++ b/src/test/ui/testExplorer.test.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The MathWorks, Inc. +import { VSCodeTester } from '../tools/tester/VSCodeTester' +import { TestExplorerTester } from '../tools/tester/TestExplorerTester' +import { before, after } from 'mocha'; + +suite('Test Explorer UI Tests', () => { + let vs: VSCodeTester + let testExplorer: TestExplorerTester + + before(async () => { + vs = new VSCodeTester(); + testExplorer = new TestExplorerTester(vs) + await vs.openEditor('hScript1.m') + await vs.assertMATLABConnected() + await vs.closeActiveEditor() + }); + + after(async () => { + await vs.disconnectFromMATLAB() + }); + + test('Test Explorer view is available and shows welcome content', async () => { + await testExplorer.openTestExplorer() + await testExplorer.assertWelcomeContentVisible() + }) + + test('Testing commands appear in Command Palette', async () => { + await testExplorer.assertCommandsInPalette(['Add Test Folder', 'Add Test File']) + }) + + test('Run All Tests does not error when no tests are configured', async () => { + await vs.executeCommand('MATLAB: Run All Tests') + await testExplorer.assertNoErrorNotifications() + }) + + test('Welcome view persists after MATLAB disconnect', async () => { + await testExplorer.openTestExplorer() + await vs.disconnectFromMATLAB() + await testExplorer.assertWelcomeContentVisible() + await vs.connectToMATLAB() + }) +}); diff --git a/src/test/unit/.mocharc.js b/src/test/unit/.mocharc.js new file mode 100644 index 0000000..e503f2d --- /dev/null +++ b/src/test/unit/.mocharc.js @@ -0,0 +1,5 @@ +// Copyright 2026 The MathWorks, Inc. +module.exports = { + spec: 'out/test/unit/**/*.test.js', + timeout: 10000 +} diff --git a/src/test/unit/CommandWindow.test.ts b/src/test/unit/CommandWindow.test.ts index 0dba6f3..c30e406 100644 --- a/src/test/unit/CommandWindow.test.ts +++ b/src/test/unit/CommandWindow.test.ts @@ -31,7 +31,7 @@ function createTestCommandWindow (): CommandWindow { mockMvm.getMatlabState = () => MatlabMVMConnectionState.CONNECTED; mockMvm.emit = () => {}; - const cw = new CommandWindow(mockMvm, mockNotifier as any); + const cw = new CommandWindow(mockMvm, mockNotifier as any, null as any); cw.open({ rows: 30, columns: 100 }); // Simulate MATLAB becoming ready — sets prompt to '>> ' and state to READY diff --git a/src/test/unit/MatlabTestDiscovery.test.ts b/src/test/unit/MatlabTestDiscovery.test.ts new file mode 100644 index 0000000..fb105a7 --- /dev/null +++ b/src/test/unit/MatlabTestDiscovery.test.ts @@ -0,0 +1,228 @@ +// Copyright 2026 The MathWorks, Inc. + +/* eslint-disable import/first */ +// Register vscode mock BEFORE any imports that depend on it +// eslint-disable-next-line @typescript-eslint/no-var-requires +const vscode = require('./mocks/vscode') +const Module = require('module') +const originalResolveFilename = Module._resolveFilename +Module._resolveFilename = function (request: string, ...args: any[]) { + if (request === 'vscode') return 'vscode' + return originalResolveFilename.call(this, request, ...args) +}; +(require as any).cache.vscode = { + id: 'vscode', filename: 'vscode', loaded: true, + exports: vscode, children: [], paths: [], path: '', isPreloading: false, require: require +} + +import * as sinon from 'sinon' +import * as assert from 'assert' +import { EventEmitter } from 'events' + +// --- Mock Factories --- + +function createMockMvm (state = 'connected'): any { + const emitter = new EventEmitter() + const mvm = Object.assign(emitter, { + getMatlabState: sinon.stub().returns(state), + feval: sinon.stub().resolves({ + result: [{ + names: ['TestA/testMethod1'], + filenames: ['/test/TestA.m'], + procedureNames: ['testMethod1'], + testParentNames: ['TestA'], + parameterizations: [''], + error: '' + }] + }), + interrupt: sinon.stub() + }) + const originalOn = emitter.on.bind(emitter) + ;(mvm as any).on = (event: string, listener: (...args: any[]) => void) => { + originalOn(event, listener) + return new vscode.Disposable(() => emitter.removeListener(event, listener)) + } + return mvm +} + +function createMockContext (): any { + const state: Record = {} + return { + workspaceState: { + get: sinon.stub().callsFake((key: string, defaultValue?: any) => { + return state[key] ?? defaultValue + }), + update: sinon.stub().callsFake((key: string, value: any) => { + state[key] = value + return Promise.resolve() + }) + }, + _state: state + } +} + +function createMockTelemetryLogger (): any { + return { logEvent: sinon.stub() } +} + +function createMockController (): any { + const items = vscode.createMockTestItemCollection() + return { + items, + createTestItem: (id: string, label: string, uri?: any) => vscode.createMockTestItem(id, label, uri), + resolveHandler: undefined as any, + dispose: sinon.stub() + } +} + +// Track FileSystemWatcher callbacks +interface WatcherCallbacks { + onCreate: Array<() => void> + onDelete: Array<() => void> + onChange: Array<() => void> +} + +function setupFileSystemWatcherMock (): WatcherCallbacks { + const callbacks: WatcherCallbacks = { onCreate: [], onDelete: [], onChange: [] } + const originalCreateFSW = vscode.workspace.createFileSystemWatcher + ;(vscode.workspace as any).createFileSystemWatcher = sinon.stub().callsFake((_pattern: string) => { + return { + onDidCreate: (cb: () => void) => { callbacks.onCreate.push(cb); return new vscode.Disposable(() => {}) }, + onDidDelete: (cb: () => void) => { callbacks.onDelete.push(cb); return new vscode.Disposable(() => {}) }, + onDidChange: (cb: () => void) => { callbacks.onChange.push(cb); return new vscode.Disposable(() => {}) }, + dispose: () => {} + } + }) + return callbacks +} + +/** Imports MatlabTestDiscovery with the mocked vscode module. */ +async function importDiscovery (): Promise { + const mod = await import('../../services/testing/MatlabTestDiscovery') + return mod.default +} + +// --- Tests --- + +describe('MatlabTestDiscovery', () => { + let MatlabTestDiscovery: any + let mockMvm: any + let mockContext: any + let mockTelemetry: any + let controller: any + let watcherCallbacks: WatcherCallbacks + let clock: sinon.SinonFakeTimers + + before(async () => { + MatlabTestDiscovery = await importDiscovery() + }) + + beforeEach(() => { + mockMvm = createMockMvm('connected') + mockContext = createMockContext() + mockTelemetry = createMockTelemetryLogger() + controller = createMockController() + watcherCallbacks = setupFileSystemWatcherMock() + }) + + afterEach(() => { + sinon.restore() + if (clock) { + clock.restore() + } + }) + + describe('FileSystemWatcher', () => { + it('should trigger re-discovery when .m file is modified', async () => { + // Pre-populate workspace state with a test folder so discovery has sources + mockContext._state['matlab.testing.folders'] = ['/workspace/tests'] + + const discovery = new MatlabTestDiscovery(controller, mockMvm, mockContext, mockTelemetry) + + // Reset feval call count from constructor's initial discovery + mockMvm.feval.resetHistory() + + // Install fake timers to control debounce + clock = sinon.useFakeTimers() + + // Simulate .m file change + assert.ok(watcherCallbacks.onChange.length > 0, 'onChange listener should be registered') + watcherCallbacks.onChange[0]() + + // Advance past the 500ms debounce + clock.tick(600) + + // Allow async discoverAll to proceed + await Promise.resolve() + + sinon.assert.called(mockMvm.feval) + }) + + it('should create a RelativePattern watcher for each registered test source', () => { + mockContext._state['matlab.testing.folders'] = ['/workspace/tests', '/external/suite'] + mockContext._state['matlab.testing.files'] = ['/external/StandaloneTest.m'] + + new MatlabTestDiscovery(controller, mockMvm, mockContext, mockTelemetry) + + const createFSW = vscode.workspace.createFileSystemWatcher as sinon.SinonStub + // One watcher per registered source (2 folders + 1 file). + sinon.assert.calledThrice(createFSW) + + const patterns = createFSW.getCalls().map(c => c.args[0]) + patterns.forEach(p => assert.ok(p instanceof vscode.RelativePattern, 'expected a RelativePattern')) + + const folderWatch = patterns.find(p => p.base === '/workspace/tests') + assert.ok(folderWatch != null, 'expected a watcher for the registered folder') + assert.strictEqual(folderWatch.pattern, '**/*.m', 'folders are watched recursively') + + const fileWatch = patterns.find(p => p.base === '/external') + assert.ok(fileWatch != null, 'expected a watcher based on the file\'s directory') + assert.strictEqual(fileWatch.pattern, 'StandaloneTest.m', 'files are watched by exact name') + }) + + it('should recreate watchers when a test source is added or removed', async () => { + const discovery = new MatlabTestDiscovery(controller, mockMvm, mockContext, mockTelemetry) + const createFSW = vscode.workspace.createFileSystemWatcher as sinon.SinonStub + + // No sources registered at construction -> no watchers created. + sinon.assert.notCalled(createFSW) + + sinon.stub(vscode.window, 'showOpenDialog').resolves([vscode.Uri.file('/external/suite')]) + await discovery.addTestFolder() + + // Adding a source spins up a watcher for it. + sinon.assert.calledOnce(createFSW) + assert.ok(createFSW.getCall(0).args[0] instanceof vscode.RelativePattern) + }) + + it('should debounce re-discovery (500ms) to avoid excessive calls', async () => { + mockContext._state['matlab.testing.folders'] = ['/workspace/tests'] + + const discovery = new MatlabTestDiscovery(controller, mockMvm, mockContext, mockTelemetry) + mockMvm.feval.resetHistory() + + clock = sinon.useFakeTimers() + + // Rapid file changes (simulating save-all or formatter) + watcherCallbacks.onChange[0]() + clock.tick(100) + watcherCallbacks.onChange[0]() + clock.tick(100) + watcherCallbacks.onChange[0]() + clock.tick(100) + watcherCallbacks.onChange[0]() + clock.tick(100) + watcherCallbacks.onChange[0]() + + // Only 400ms elapsed since last change — should NOT have discovered yet + assert.strictEqual(mockMvm.feval.callCount, 0) + + // Advance past debounce threshold (500ms from last change) + clock.tick(600) + await Promise.resolve() + + // Should trigger exactly one discovery + sinon.assert.calledOnce(mockMvm.feval) + }) + }) +}) diff --git a/src/test/unit/MatlabTestRunner.test.ts b/src/test/unit/MatlabTestRunner.test.ts new file mode 100644 index 0000000..e85120c --- /dev/null +++ b/src/test/unit/MatlabTestRunner.test.ts @@ -0,0 +1,447 @@ +// Copyright 2026 The MathWorks, Inc. + +/* eslint-disable import/first */ +// Register vscode mock BEFORE any imports that depend on it +// eslint-disable-next-line @typescript-eslint/no-var-requires +const vscode = require('./mocks/vscode') +const Module = require('module') +const originalResolveFilename = Module._resolveFilename +Module._resolveFilename = function (request: string, ...args: any[]) { + if (request === 'vscode') return 'vscode' + return originalResolveFilename.call(this, request, ...args) +}; +(require as any).cache.vscode = { + id: 'vscode', filename: 'vscode', loaded: true, + exports: vscode, children: [], paths: [], path: '', isPreloading: false, require: require +} + +import * as sinon from 'sinon' +import * as assert from 'assert' +import { EventEmitter } from 'events' +import Notification from '../../notifications/Notifications' + +// --- Mock Factories --- + +interface MockMVM extends EventEmitter { + getMatlabState: sinon.SinonStub + feval: sinon.SinonStub + interrupt: sinon.SinonStub + on: (event: string, listener: (...args: any[]) => void) => any +} + +function createMockMvm (state = 'connected'): MockMVM { + const emitter = new EventEmitter() + const mvm = Object.assign(emitter, { + getMatlabState: sinon.stub().returns(state), + feval: sinon.stub().resolves({}), + interrupt: sinon.stub() + }) + // Make on() return a Disposable + const originalOn = emitter.on.bind(emitter) + ;(mvm as any).on = (event: string, listener: (...args: any[]) => void) => { + originalOn(event, listener) + return new vscode.Disposable(() => emitter.removeListener(event, listener)) + } + return mvm as unknown as MockMVM +} + +function createMockClient (): any { + const listeners: Record = {} + return { + onNotification: sinon.stub().callsFake((name: string, handler: Function) => { + listeners[name] = handler + return new vscode.Disposable(() => { delete listeners[name] }) + }), + sendNotification: sinon.stub(), + _trigger: (name: string, data: any) => { + if (listeners[name]) listeners[name](data) + } + } +} + +function createMockTelemetryLogger (): any { + return { logEvent: sinon.stub() } +} + +function createStubbedTestRun (): any { + return { + enqueued: sinon.stub(), + started: sinon.stub(), + passed: sinon.stub(), + failed: sinon.stub(), + errored: sinon.stub(), + skipped: sinon.stub(), + appendOutput: sinon.stub(), + end: sinon.stub() + } +} + +function createStubbedController (run: any): any { + const items = vscode.createMockTestItemCollection() + return { + items, + createTestItem: (id: string, label: string, uri?: any) => vscode.createMockTestItem(id, label, uri), + createRunProfile: sinon.stub().callsFake((_label: string, _kind: any, handler: Function) => { + return { runHandler: handler, dispose: sinon.stub() } + }), + createTestRun: sinon.stub().returns(run), + resolveHandler: undefined, + dispose: sinon.stub() + } +} + +// --- Helpers --- + +/** Imports MatlabTestRunner with the mocked vscode module. */ +async function importRunner (): Promise { + const mod = await import('../../services/testing/MatlabTestRunner') + return mod.default +} + +/** Creates a test item and adds it to the controller. */ +function addTestItemToController (controller: any, id: string, label: string, filePath: string): any { + const uri = vscode.Uri.file(filePath) + const item = vscode.createMockTestItem(id, label, uri) + controller.items.add(item) + return item +} + +// --- Tests --- + +describe('MatlabTestRunner', () => { + let MatlabTestRunner: any + let mockMvm: MockMVM + let mockClient: any + let mockTelemetry: any + let mockRun: any + let controller: any + let runner: any + let runHandler: Function + + before(async () => { + MatlabTestRunner = await importRunner() + }) + + beforeEach(() => { + mockMvm = createMockMvm('connected') + mockClient = createMockClient() + mockTelemetry = createMockTelemetryLogger() + mockRun = createStubbedTestRun() + controller = createStubbedController(mockRun) + + runner = new MatlabTestRunner(controller, mockClient, mockMvm, mockTelemetry) + runHandler = controller.createRunProfile.firstCall.args[2] + }) + + afterEach(() => { + sinon.restore() + }) + + describe('Test Result Counts', () => { + it('should report pass/fail/skip counts correctly after test run completes', async () => { + const item1 = addTestItemToController(controller, 'TestA/testPass', 'testPass', '/test.m') + const item2 = addTestItemToController(controller, 'TestA/testFail', 'testFail', '/test.m') + const item3 = addTestItemToController(controller, 'TestA/testSkip', 'testSkip', '/test.m') + + const request = new vscode.TestRunRequest([item1, item2, item3]) + const tokenSource = new vscode.CancellationTokenSource() + await runHandler(request, tokenSource.token) + + const runId = mockClient.sendNotification.firstCall.args[1].runId + + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { type: 'started', testName: 'TestA/testPass', testFile: '/test.m' } + }) + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { type: 'finished', testName: 'TestA/testPass', testFile: '/test.m', status: 'passed', duration: 0.1 } + }) + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { type: 'started', testName: 'TestA/testFail', testFile: '/test.m' } + }) + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { type: 'finished', testName: 'TestA/testFail', testFile: '/test.m', status: 'failed', diagnostics: [{ message: 'Expected 2, got 3' }] } + }) + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { type: 'started', testName: 'TestA/testSkip', testFile: '/test.m' } + }) + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { type: 'finished', testName: 'TestA/testSkip', testFile: '/test.m', status: 'incomplete' } + }) + + sinon.assert.calledOnce(mockRun.passed) + sinon.assert.calledOnce(mockRun.failed) + sinon.assert.calledOnce(mockRun.skipped) + }) + + it('should update counts correctly on re-run after fixing a test', async () => { + const item1 = addTestItemToController(controller, 'TestA/testFix', 'testFix', '/test.m') + + // First run: test fails + const request1 = new vscode.TestRunRequest([item1]) + const token1 = new vscode.CancellationTokenSource() + await runHandler(request1, token1.token) + + const runId1 = mockClient.sendNotification.firstCall.args[1].runId + mockClient._trigger(Notification.TestRunEvent, { + runId: runId1, + event: { type: 'finished', testName: 'TestA/testFix', testFile: '/test.m', status: 'failed', diagnostics: [{ message: 'assertion failed' }] } + }) + mockClient._trigger(Notification.TestRunComplete, { runId: runId1 }) + + sinon.assert.calledOnce(mockRun.failed) + assert.strictEqual(mockRun.passed.callCount, 0) + + // Second run: test passes (create fresh run) + const mockRun2 = createStubbedTestRun() + controller.createTestRun.returns(mockRun2) + + const request2 = new vscode.TestRunRequest([item1]) + const token2 = new vscode.CancellationTokenSource() + await runHandler(request2, token2.token) + + const runId2 = mockClient.sendNotification.secondCall.args[1].runId + mockClient._trigger(Notification.TestRunEvent, { + runId: runId2, + event: { type: 'finished', testName: 'TestA/testFix', testFile: '/test.m', status: 'passed', duration: 0.05 } + }) + + sinon.assert.calledOnce(mockRun2.passed) + assert.strictEqual(mockRun2.failed.callCount, 0) + }) + + }) + + describe('Real-time Streaming', () => { + it('should stream results in real-time (not batched at completion)', async () => { + const item1 = addTestItemToController(controller, 'T/test1', 'test1', '/t.m') + const item2 = addTestItemToController(controller, 'T/test2', 'test2', '/t.m') + + const request = new vscode.TestRunRequest([item1, item2]) + const tokenSource = new vscode.CancellationTokenSource() + await runHandler(request, tokenSource.token) + + const runId = mockClient.sendNotification.firstCall.args[1].runId + + // First test starts and finishes — results applied immediately + mockClient._trigger(Notification.TestRunEvent, { runId, event: { type: 'started', testName: 'T/test1', testFile: '/t.m' } }) + sinon.assert.calledOnce(mockRun.started) + sinon.assert.calledWith(mockRun.started, item1) + + mockClient._trigger(Notification.TestRunEvent, { runId, event: { type: 'finished', testName: 'T/test1', testFile: '/t.m', status: 'passed', duration: 0.5 } }) + sinon.assert.calledOnce(mockRun.passed) + + // Second test hasn't started yet — verify no batching + assert.strictEqual(mockRun.started.callCount, 1) + + // Second test starts — incremental update + mockClient._trigger(Notification.TestRunEvent, { runId, event: { type: 'started', testName: 'T/test2', testFile: '/t.m' } }) + assert.strictEqual(mockRun.started.callCount, 2) + sinon.assert.calledWith(mockRun.started.secondCall, item2) + }) + }) + + describe('Diagnostics', () => { + it('should display structured diagnostic message for failed test', async () => { + const item = addTestItemToController(controller, 'T/testFail', 'testFail', '/test.m') + + const request = new vscode.TestRunRequest([item]) + const tokenSource = new vscode.CancellationTokenSource() + await runHandler(request, tokenSource.token) + + const runId = mockClient.sendNotification.firstCall.args[1].runId + + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { + type: 'finished', + testName: 'T/testFail', + testFile: '/test.m', + status: 'failed', + duration: 0.3, + diagnostics: [{ + message: 'Verification failed: 1+1 is not equal to 3', + failedInFile: '/src/TestClass.m', + failedOnLine: 15, + stack: [{ file: '/src/TestClass.m', name: 'testFail', line: 15 }] + }] + } + }) + + sinon.assert.calledOnce(mockRun.failed) + const failedCall = mockRun.failed.firstCall + assert.strictEqual(failedCall.args[0], item) + + const messages = failedCall.args[1] + assert.strictEqual(messages.length, 1) + assert.strictEqual(messages[0].message, 'Verification failed: 1+1 is not equal to 3') + assert.ok(messages[0].location) + assert.strictEqual(messages[0].location.uri.fsPath, '/src/TestClass.m') + assert.strictEqual(messages[0].location.range.line, 14) // 0-indexed + }) + + it('should show all diagnostics when a single test has multiple failures', async () => { + const item = addTestItemToController(controller, 'T/testMulti', 'testMulti', '/test.m') + + const request = new vscode.TestRunRequest([item]) + const tokenSource = new vscode.CancellationTokenSource() + await runHandler(request, tokenSource.token) + + const runId = mockClient.sendNotification.firstCall.args[1].runId + + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { + type: 'finished', + testName: 'T/testMulti', + testFile: '/test.m', + status: 'failed', + duration: 0.5, + diagnostics: [ + { message: 'First assertion failed', failedInFile: '/test.m', failedOnLine: 10, stack: [] }, + { message: 'Second assertion failed', failedInFile: '/test.m', failedOnLine: 12, stack: [] }, + { message: 'Third assertion failed', failedInFile: '/test.m', failedOnLine: 14, stack: [] } + ] + } + }) + + sinon.assert.calledOnce(mockRun.failed) + const messages = mockRun.failed.firstCall.args[1] + assert.strictEqual(messages.length, 3) + assert.strictEqual(messages[0].message, 'First assertion failed') + assert.strictEqual(messages[1].message, 'Second assertion failed') + assert.strictEqual(messages[2].message, 'Third assertion failed') + }) + + it('should report incomplete tests as skipped (not errored)', async () => { + const item = addTestItemToController(controller, 'T/testError', 'testError', '/test.m') + + const request = new vscode.TestRunRequest([item]) + const tokenSource = new vscode.CancellationTokenSource() + await runHandler(request, tokenSource.token) + + const runId = mockClient.sendNotification.firstCall.args[1].runId + + // Incomplete with a real diagnostic (not just 'Test failed') -> errored + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { + type: 'finished', + testName: 'T/testError', + testFile: '/test.m', + status: 'incomplete', + duration: 0.1, + diagnostics: [{ + message: 'Error using foo\nUndefined function or variable "x"', + failedInFile: '/src/foo.m', + failedOnLine: 7, + stack: [{ file: '/src/foo.m', name: 'foo', line: 7 }] + }] + } + }) + + sinon.assert.calledOnce(mockRun.skipped) + assert.strictEqual(mockRun.errored.callCount, 0) + }) + + it('should set correct location for navigation with nested stack frames', async () => { + const item = addTestItemToController(controller, 'T/testNested', 'testNested', '/test.m') + + const request = new vscode.TestRunRequest([item]) + const tokenSource = new vscode.CancellationTokenSource() + await runHandler(request, tokenSource.token) + + const runId = mockClient.sendNotification.firstCall.args[1].runId + + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { + type: 'finished', + testName: 'T/testNested', + testFile: '/test.m', + status: 'failed', + duration: 0.2, + diagnostics: [{ + message: 'Assertion failed in helper', + failedInFile: '/src/helpers/validateInput.m', + failedOnLine: 23, + stack: [ + { file: '/src/helpers/validateInput.m', name: 'validateInput', line: 23 }, + { file: '/src/TestClass.m', name: 'testNested', line: 45 }, + { file: '/src/TestClass.m', name: 'setup', line: 10 } + ] + }] + } + }) + + sinon.assert.calledOnce(mockRun.failed) + const message = mockRun.failed.firstCall.args[1][0] + + // Location should point to failedInFile/failedOnLine (top of stack) + assert.strictEqual(message.location.uri.fsPath, '/src/helpers/validateInput.m') + assert.strictEqual(message.location.range.line, 22) // 0-indexed (23 - 1) + }) + + }) + + describe('Cancellation', () => { + it('should interrupt running tests via mvm.interrupt() when cancelled', async () => { + const item = addTestItemToController(controller, 'T/testLong', 'testLong', '/test.m') + + const request = new vscode.TestRunRequest([item]) + const tokenSource = new vscode.CancellationTokenSource() + await runHandler(request, tokenSource.token) + + // Verify interrupt not called yet + sinon.assert.notCalled(mockMvm.interrupt) + + // Cancel + tokenSource.cancel() + + sinon.assert.calledOnce(mockMvm.interrupt) + }) + + it('should preserve partial results from completed tests after cancel', async () => { + const item1 = addTestItemToController(controller, 'T/test1', 'test1', '/test.m') + const item2 = addTestItemToController(controller, 'T/test2', 'test2', '/test.m') + const item3 = addTestItemToController(controller, 'T/test3', 'test3', '/test.m') + + const request = new vscode.TestRunRequest([item1, item2, item3]) + const tokenSource = new vscode.CancellationTokenSource() + await runHandler(request, tokenSource.token) + + const runId = mockClient.sendNotification.firstCall.args[1].runId + + // First test completes successfully + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { type: 'finished', testName: 'T/test1', testFile: '/test.m', status: 'passed', duration: 0.1 } + }) + + // Second test starts + mockClient._trigger(Notification.TestRunEvent, { + runId, + event: { type: 'started', testName: 'T/test2', testFile: '/test.m' } + }) + + // Cancel mid-run + tokenSource.cancel() + + // Verify first test result is preserved + sinon.assert.calledOnce(mockRun.passed) + sinon.assert.calledWith(mockRun.passed, item1) + + // Complete the run (server sends complete after interrupt) + mockClient._trigger(Notification.TestRunComplete, { runId }) + + sinon.assert.calledOnce(mockRun.end) + // Passed result from test1 is still recorded + assert.strictEqual(mockRun.passed.callCount, 1) + }) + }) +}) diff --git a/src/test/unit/TelemetryLogger.test.ts b/src/test/unit/TelemetryLogger.test.ts new file mode 100644 index 0000000..7ff3212 --- /dev/null +++ b/src/test/unit/TelemetryLogger.test.ts @@ -0,0 +1,215 @@ +// Copyright 2026 The MathWorks, Inc. + +/* eslint-disable import/first */ + +// Use the unified vscode mock (registered by runTest.ts via registerMockVscode) +// eslint-disable-next-line @typescript-eslint/no-var-requires +const vscode = require('./mocks/vscode') + +// Mock node-fetch — capture calls without hitting the network +import mock = require('mock-require') + +const fetchCalls: Array<{ url: string, init: any }> = [] +let fetchResponse: { ok: boolean, status: number, statusText: string } = { + ok: true, + status: 200, + statusText: 'OK' +} +let fetchShouldReject: Error | undefined + +const fetchStub = (url: string, init: any): Promise => { + fetchCalls.push({ url, init }) + if (fetchShouldReject !== undefined) { + return Promise.reject(fetchShouldReject) + } + return Promise.resolve(fetchResponse) +} +mock('node-fetch', { default: fetchStub, __esModule: true }) + +import * as assert from 'assert' +import { suite, test, setup, teardown } from 'mocha' +import TelemetryLogger, { TelemetryEvent } from '../../services/telemetry/TelemetryLogger' + +// Wait for the fetch promise chain (.then/.catch) inside sendEvent to settle. +async function flushMicrotasks (): Promise { + await new Promise(resolve => setImmediate(resolve)) +} + +const EXTENSION_VERSION = '1.2.3' + +function makeEvent (overrides: Partial = {}): TelemetryEvent { + return { + eventKey: 'ML_VS_CODE_SOME_EVENT', + data: { foo: 'bar' }, + ...overrides + } +} + +suite('TelemetryLogger', () => { + let logger: TelemetryLogger + let consoleErrorStub: (...args: unknown[]) => void + let consoleErrorCalls: unknown[][] + let originalConsoleError: typeof console.error + + setup(() => { + // Reset shared mock state + vscode._state.isTelemetryEnabled = true + vscode._state.sessionId = 'test-session-id' + vscode._state.telemetrySetting = true + fetchCalls.length = 0 + fetchResponse = { ok: true, status: 200, statusText: 'OK' } + fetchShouldReject = undefined + + // Silence and capture console.error so failure-path tests can inspect it + consoleErrorCalls = [] + originalConsoleError = console.error + consoleErrorStub = (...args: unknown[]) => { consoleErrorCalls.push(args) } + console.error = consoleErrorStub as typeof console.error + + logger = new TelemetryLogger(EXTENSION_VERSION) + }) + + teardown(() => { + console.error = originalConsoleError + }) + + suite('shouldLogTelemetry gating', () => { + test('does not send when VS Code telemetry is disabled', async () => { + vscode._state.isTelemetryEnabled = false + + logger.logEvent(makeEvent()) + await flushMicrotasks() + + assert.strictEqual(fetchCalls.length, 0) + }) + + test('does not send when VS Code telemetry is disabled, even for the telemetry setting change event', async () => { + vscode._state.isTelemetryEnabled = false + + logger.logEvent({ + eventKey: 'ML_VS_CODE_SETTING_CHANGE', + data: { setting_name: 'telemetry' } + }) + await flushMicrotasks() + + assert.strictEqual(fetchCalls.length, 0) + }) + + test('sends when both VS Code telemetry and matlab.telemetry are enabled', async () => { + vscode._state.isTelemetryEnabled = true + vscode._state.telemetrySetting = true + + logger.logEvent(makeEvent()) + await flushMicrotasks() + + assert.strictEqual(fetchCalls.length, 1) + }) + + test('does not send when matlab.telemetry is disabled', async () => { + vscode._state.telemetrySetting = false + + logger.logEvent(makeEvent()) + await flushMicrotasks() + + assert.strictEqual(fetchCalls.length, 0) + }) + + test('sends the telemetry setting-change event even when matlab.telemetry is disabled', async () => { + vscode._state.telemetrySetting = false + + logger.logEvent({ + eventKey: 'ML_VS_CODE_SETTING_CHANGE', + data: { setting_name: 'telemetry' } + }) + await flushMicrotasks() + + assert.strictEqual(fetchCalls.length, 1) + }) + }) + + suite('sendEvent request shape', () => { + test('POSTs to the configured endpoint', async () => { + logger.logEvent(makeEvent()) + await flushMicrotasks() + + assert.strictEqual(fetchCalls.length, 1) + assert.strictEqual(fetchCalls[0].init.method, 'POST') + }) + + test('includes all required UDC headers with the extension version', async () => { + logger.logEvent(makeEvent()) + await flushMicrotasks() + + const headers = fetchCalls[0].init.headers + assert.strictEqual(headers['Content-Type'], 'application/json') + assert.strictEqual(headers['x-mw-udc-client-version'], '1.0') + assert.strictEqual(headers['x-mw-udc-application-name'], 'MATLAB_EXTENSION_FOR_VSCODE') + assert.strictEqual(headers['x-mw-udc-application-version'], EXTENSION_VERSION) + }) + + test('wraps event data in the UDC envelope with the correct product', async () => { + const data = { setting_name: 'installPath', new_value: '/opt/matlab' } + logger.logEvent({ eventKey: 'ML_VS_CODE_SETTING_CHANGE', data }) + await flushMicrotasks() + + const body = JSON.parse(fetchCalls[0].init.body) + assert.strictEqual(body.Event.length, 1) + + const entry = body.Event[0] + assert.strictEqual(entry.sessionKey, 'test-session-id') + assert.strictEqual(entry.eventKey, 'ML_VS_CODE_SETTING_CHANGE') + assert.ok(typeof entry.eventDate === 'string' && entry.eventDate.length > 0) + + const eventData = JSON.parse(entry.eventData) + assert.deepStrictEqual(eventData, { + logDDUXData: { + product: 'ML_VS_CODE', + keyValues: data + } + }) + }) + + test('event date is a valid ISO-8601 timestamp with the trailing Z sliced off', async () => { + logger.logEvent(makeEvent()) + await flushMicrotasks() + + const eventDate: string = JSON.parse(fetchCalls[0].init.body).Event[0].eventDate + // Should look like "2026-07-06T12:34:56.789" — 23 chars, no trailing Z + assert.ok(!eventDate.endsWith('Z'), `eventDate should not end in Z, got "${eventDate}"`) + assert.ok(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/.test(eventDate), + `eventDate should be ISO-8601 without trailing Z, got "${eventDate}"`) + }) + }) + + suite('failure handling', () => { + test('logs to console.error when the response is not ok', async () => { + fetchResponse = { ok: false, status: 500, statusText: 'Internal Server Error' } + + logger.logEvent(makeEvent()) + await flushMicrotasks() + + assert.strictEqual(consoleErrorCalls.length, 1) + const message = String(consoleErrorCalls[0][0]) + assert.ok(message.includes('500'), `expected message to include status code, got: ${message}`) + assert.ok(message.includes('Internal Server Error'), + `expected message to include status text, got: ${message}`) + }) + + test('logs to console.error when fetch itself rejects', async () => { + fetchShouldReject = new Error('network down') + + logger.logEvent(makeEvent()) + await flushMicrotasks() + + assert.strictEqual(consoleErrorCalls.length, 1) + assert.strictEqual(consoleErrorCalls[0][0], 'Telemetry post error: ') + assert.strictEqual((consoleErrorCalls[0][1] as Error).message, 'network down') + }) + + test('does not throw synchronously when fetch rejects', () => { + fetchShouldReject = new Error('network down') + + assert.doesNotThrow(() => logger.logEvent(makeEvent())) + }) + }) +}) diff --git a/src/test/unit/mock-vscode.ts b/src/test/unit/mock-vscode.ts index 1820eca..90a088b 100644 --- a/src/test/unit/mock-vscode.ts +++ b/src/test/unit/mock-vscode.ts @@ -1,72 +1,25 @@ // Copyright 2026 The MathWorks, Inc. -/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-empty-function, @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any */ /** - * Minimal mock of the 'vscode' module for unit testing CommandWindow. - * Must be registered before importing CommandWindow. + * Registers the unified vscode mock into Node's module cache. + * Must be called before importing any module that depends on 'vscode'. */ -class MockEventEmitter { - private _listeners: Array<(e: T) => void> = []; - - event = (listener: (e: T) => void): { dispose: () => void } => { - this._listeners.push(listener); - return { dispose: () => {} }; - }; - - fire (data: T): void { - for (const l of this._listeners) l(data); - } - - dispose (): void { - this._listeners = []; - } -} - -class MockDisposable { - dispose (): void {} -} - -const mockVscode = { - EventEmitter: MockEventEmitter, - Disposable: MockDisposable, - commands: { - executeCommand: (..._args: unknown[]) => Promise.resolve() - }, - window: { - onDidOpenTerminal: () => ({ dispose: () => {} }), - onDidCloseTerminal: () => ({ dispose: () => {} }), - onDidChangeActiveTerminal: () => ({ dispose: () => {} }), - createTerminal: () => ({}), - registerTerminalProfileProvider: () => ({ dispose: () => {} }) - }, - workspace: { - getConfiguration: () => ({ - get: () => [], - update: () => Promise.resolve() - }) - }, - env: { - clipboard: { - readText: () => Promise.resolve(''), - writeText: () => Promise.resolve() - } - } -}; +const mockVscode = require('./mocks/vscode') export function registerMockVscode (): void { - // Inject 'vscode' into Node's module cache without resolving it on disk const Module = require('module'); const originalResolveFilename = Module._resolveFilename; Module._resolveFilename = function (request: string, ...args: any[]) { if (request === 'vscode') { - return 'vscode'; // Return a fake path + return 'vscode'; } return originalResolveFilename.call(this, request, ...args); }; - require.cache.vscode = { + (require as any).cache.vscode = { id: 'vscode', filename: 'vscode', loaded: true, diff --git a/src/test/unit/mocks/vscode.ts b/src/test/unit/mocks/vscode.ts new file mode 100644 index 0000000..5402aae --- /dev/null +++ b/src/test/unit/mocks/vscode.ts @@ -0,0 +1,235 @@ +// Copyright 2026 The MathWorks, Inc. +// Unified mock of the 'vscode' module for all unit tests. + +/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-explicit-any */ + +export class Disposable { + private readonly callOnDispose: () => void + constructor (callOnDispose: () => void = () => {}) { + this.callOnDispose = callOnDispose + } + + dispose (): void { + this.callOnDispose() + } +} + +export class EventEmitter { + private _listeners: Array<(e: T) => void> = [] + + event = (listener: (e: T) => void): { dispose: () => void } => { + this._listeners.push(listener) + return { dispose: () => {} } + } + + fire (data: T): void { + for (const l of this._listeners) l(data) + } + + dispose (): void { + this._listeners = [] + } +} + +export class Uri { + readonly fsPath: string + readonly scheme: string + + private constructor (fsPath: string) { + this.fsPath = fsPath + this.scheme = 'file' + } + + static file (path: string): Uri { + return new Uri(path) + } + + toString (): string { + return `file://${this.fsPath}` + } +} + +export class RelativePattern { + readonly baseUri: Uri + readonly base: string + + constructor (base: Uri | string, public readonly pattern: string) { + if (typeof base === 'string') { + this.base = base + this.baseUri = Uri.file(base) + } else { + this.baseUri = base + this.base = base.fsPath + } + } +} + +export class Position { + constructor (public readonly line: number, public readonly character: number) {} +} + +export class Range { + constructor ( + public readonly startLine: number, + public readonly startCharacter: number, + public readonly endLine: number, + public readonly endCharacter: number + ) {} +} + +export class Location { + constructor (public readonly uri: Uri, public readonly range: Position | Range) {} +} + +export class TestMessage { + public location?: Location + + constructor (public readonly message: string) {} + + static diff (message: string, _expected: string, _actual: string): TestMessage { + return new TestMessage(message) + } +} + +export class CancellationTokenSource { + private _listeners: Array<() => void> = [] + private _isCancelled = false + + token = { + isCancellationRequested: false, + onCancellationRequested: (listener: () => void) => { + this._listeners.push(listener) + return new Disposable(() => {}) + } + } + + cancel (): void { + this._isCancelled = true + this.token.isCancellationRequested = true + this._listeners.forEach(l => l()) + } + + dispose (): void {} +} + +export enum TestRunProfileKind { + Run = 1, + Debug = 2, + Coverage = 3 +} + +// Mutable state for tests that need to control vscode.env / workspace.getConfiguration +export const _state = { + isTelemetryEnabled: true, + sessionId: 'test-session-id', + telemetrySetting: true as boolean | undefined +} + +export const env = { + get isTelemetryEnabled () { return _state.isTelemetryEnabled }, + get sessionId () { return _state.sessionId }, + clipboard: { + readText: async () => '', + writeText: async () => {} + } +} + +export const window = { + showWarningMessage: async (_msg: string) => undefined, + showErrorMessage: async (_msg: string) => undefined, + showInformationMessage: async (_msg: string) => undefined, + showOpenDialog: async (_options: any) => undefined, + onDidOpenTerminal: () => ({ dispose: () => {} }), + onDidCloseTerminal: () => ({ dispose: () => {} }), + onDidChangeActiveTerminal: () => ({ dispose: () => {} }), + createTerminal: () => ({}), + registerTerminalProfileProvider: () => ({ dispose: () => {} }) +} + +export const workspace = { + createFileSystemWatcher: (_pattern: string) => { + const watcher: any = { + onDidCreate: (_cb: () => void) => new Disposable(() => {}), + onDidDelete: (_cb: () => void) => new Disposable(() => {}), + onDidChange: (_cb: () => void) => new Disposable(() => {}), + dispose: () => {} + } + return watcher + }, + getConfiguration: (_section?: string) => ({ + get: (_key: string) => _state.telemetrySetting, + update: () => Promise.resolve() + }), + workspaceFolders: undefined as any +} + +export const tests = { + createTestController: (_id: string, _label: string) => createMockTestController() +} + +export const commands = { + registerCommand: (_command: string, _callback: (...args: any[]) => any) => new Disposable(() => {}), + executeCommand: async (_command: string, ..._args: any[]) => undefined +} + +export function createMockTestController (): any { + const items = createMockTestItemCollection() + return { + items, + createTestItem: (id: string, label: string, uri?: Uri) => createMockTestItem(id, label, uri), + createRunProfile: (_label: string, _kind: TestRunProfileKind, handler: any) => { + return { runHandler: handler, dispose: () => {} } + }, + createTestRun: (_request: any) => createMockTestRun(), + resolveHandler: undefined as any, + dispose: () => {} + } +} + +export function createMockTestRun (): any { + return { + enqueued: () => {}, + started: () => {}, + passed: () => {}, + failed: () => {}, + errored: () => {}, + skipped: () => {}, + end: () => {} + } +} + +export function createMockTestItem (id: string, label: string, uri?: Uri): any { + const children = createMockTestItemCollection() + return { + id, + label, + uri, + range: undefined as any, + canResolveChildren: false, + children, + parent: undefined as any + } +} + +export function createMockTestItemCollection (): any { + const map = new Map() + return { + get: (id: string) => map.get(id), + add: (item: any) => map.set(item.id, item), + delete: (id: string) => map.delete(id), + replace: (items: any[]) => { + map.clear() + items.forEach((item: any) => map.set(item.id, item)) + }, + forEach: (cb: (item: any) => void) => map.forEach(cb), + get size () { return map.size } + } +} + +export class TestRunRequest { + constructor ( + public readonly include?: any[], + public readonly exclude?: any[], + public readonly profile?: any + ) {} +} diff --git a/src/test/unit/runTest.ts b/src/test/unit/runTest.ts index b1352f3..1306e0d 100644 --- a/src/test/unit/runTest.ts +++ b/src/test/unit/runTest.ts @@ -5,12 +5,10 @@ import * as path from 'path' import * as Mocha from 'mocha' import * as glob from 'glob' -// Register mock before any test imports that depend on vscode registerMockVscode() - async function runTests (): Promise { const mocha = new Mocha({ - ui: 'tdd', + ui: 'bdd', reporter: 'spec' }) diff --git a/src/test/workspacebrowser/icons.test.ts b/src/test/workspacebrowser/icons.test.ts index 0efa43c..481f70c 100644 --- a/src/test/workspacebrowser/icons.test.ts +++ b/src/test/workspacebrowser/icons.test.ts @@ -21,6 +21,7 @@ suite('getIconFilename', () => { ['char', 'wsCharacter.svg'], ['logical', 'wsCheck.svg'], ['duration', 'wsClock.svg'], + ['dictionary', 'wsDataDictionary.svg'], ['datetime', 'wsDate.svg'], ['default', 'wsDefault.svg'], ['categorical', 'wsDots.svg'], diff --git a/src/test/workspacebrowser/provider/lifecycle.test.ts b/src/test/workspacebrowser/provider/lifecycle.test.ts index 687386d..916d4ef 100644 --- a/src/test/workspacebrowser/provider/lifecycle.test.ts +++ b/src/test/workspacebrowser/provider/lifecycle.test.ts @@ -21,10 +21,12 @@ function createProviderComponents (): { on: sinon.SinonStub } stateChangedCallback: (oldState: string, newState: string) => void + promptChangeCallback: (state: string, isIdle: boolean) => void sendNotification: sinon.SinonStub telemetryLogger: { logEvent: sinon.SinonStub } } { let stateChangedCallback: ((oldState: string, newState: string) => void) | undefined + let promptChangeCallback: ((state: string, isIdle: boolean) => void) | undefined const sendNotification = sinon.stub() const onNotification = sinon.stub().returns({ dispose: () => {} }) @@ -39,6 +41,8 @@ function createProviderComponents (): { on: sinon.stub().callsFake((event: string, cb: (...args: any[]) => void) => { if (event === 'stateChanged') { stateChangedCallback = cb + } else if (event === 'promptChange') { + promptChangeCallback = cb } return { dispose: () => {} } }) @@ -53,7 +57,7 @@ function createProviderComponents (): { const telemetryLogger = { logEvent: sinon.stub() } const provider = new WorkspaceBrowserProvider(context as any, notifier as any, mvm as any, telemetryLogger as any) - return { provider, mvm, stateChangedCallback: stateChangedCallback!, sendNotification, telemetryLogger } + return { provider, mvm, stateChangedCallback: stateChangedCallback!, promptChangeCallback: promptChangeCallback!, sendNotification, telemetryLogger } } function createWebviewView (): { webviewView: any, postMessage: sinon.SinonStub, triggerDispose: () => void } { @@ -105,35 +109,6 @@ suite('WorkspaceBrowserProvider — lifecycle', () => { expect(webviewView.webview.html).to.include('or later') }) - test('logs wsbPanelOpened telemetry event', () => { - const { provider, telemetryLogger } = createProviderComponents() - const { webviewView } = createWebviewView() - - provider.resolveWebviewView(webviewView, {} as any, { isCancellationRequested: false } as any) - - expect(telemetryLogger.logEvent.calledOnce).to.be.true - expect(telemetryLogger.logEvent.firstCall.args[0]).to.deep.equal({ - eventKey: 'ML_VS_CODE_ACTIONS', - data: { action_type: 'wsbPanelOpened', result: '' } - }) - }) - - test('logs wsbPanelClosed telemetry event on dispose', () => { - const { provider, telemetryLogger } = createProviderComponents() - const { webviewView, triggerDispose } = createWebviewView() - - provider.resolveWebviewView(webviewView, {} as any, { isCancellationRequested: false } as any) - telemetryLogger.logEvent.resetHistory() - - triggerDispose() - - expect(telemetryLogger.logEvent.calledOnce).to.be.true - expect(telemetryLogger.logEvent.firstCall.args[0]).to.deep.equal({ - eventKey: 'ML_VS_CODE_ACTIONS', - data: { action_type: 'wsbPanelClosed', result: '' } - }) - }) - test('shows full interactive HTML when MATLAB is connected and supported', () => { const { provider, mvm } = createProviderComponents() mvm.getMatlabState.returns('connected') @@ -204,4 +179,90 @@ suite('WorkspaceBrowserProvider — lifecycle', () => { expect(webviewView.webview.html).to.include('R2023a') }) }) + + // ── Prompt-Idle Priming ───────────────────────────────────────── + // The prime workaround only targets R2023a/R2023b where the backend's change + // listener goes dormant after `clear`. On R2024a+ the eval must be skipped. + + suite('prompt-idle priming', () => { + const PRIME_CMD = 'workspace__init__2981022=1;clear workspace__init__2981022;' + let clock: sinon.SinonFakeTimers + + setup(() => { + clock = sinon.useFakeTimers() + }) + + teardown(() => { + clock.restore() + }) + + test('fires eval for R2023b on idle', () => { + const { mvm, stateChangedCallback, promptChangeCallback } = createProviderComponents() + mvm.getMatlabRelease.returns('R2023b') + stateChangedCallback('disconnected', 'connected') + mvm.eval.resetHistory() + + promptChangeCallback('', true) + clock.tick(300) + + expect(mvm.eval.calledWith(PRIME_CMD, false)).to.be.true + }) + + test('fires eval for R2023a on idle', () => { + const { mvm, stateChangedCallback, promptChangeCallback } = createProviderComponents() + mvm.getMatlabRelease.returns('R2023a') + stateChangedCallback('disconnected', 'connected') + mvm.eval.resetHistory() + + promptChangeCallback('', true) + clock.tick(300) + + expect(mvm.eval.calledWith(PRIME_CMD, false)).to.be.true + }) + + test('does NOT fire eval for R2024a on idle', () => { + const { mvm, stateChangedCallback, promptChangeCallback } = createProviderComponents() + mvm.getMatlabRelease.returns('R2024a') + stateChangedCallback('disconnected', 'connected') + mvm.eval.resetHistory() + + promptChangeCallback('', true) + clock.tick(300) + + const primeCall = mvm.eval.getCalls().find( + (c: sinon.SinonSpyCall) => c.args[0] === PRIME_CMD + ) + expect(primeCall).to.be.undefined + }) + + test('does NOT fire eval when release is null', () => { + const { mvm, stateChangedCallback, promptChangeCallback } = createProviderComponents() + mvm.getMatlabRelease.returns(null) + stateChangedCallback('disconnected', 'connected') + mvm.eval.resetHistory() + + promptChangeCallback('', true) + clock.tick(300) + + const primeCall = mvm.eval.getCalls().find( + (c: sinon.SinonSpyCall) => c.args[0] === PRIME_CMD + ) + expect(primeCall).to.be.undefined + }) + + test('does NOT fire eval when not idle', () => { + const { mvm, stateChangedCallback, promptChangeCallback } = createProviderComponents() + mvm.getMatlabRelease.returns('R2023b') + stateChangedCallback('disconnected', 'connected') + mvm.eval.resetHistory() + + promptChangeCallback('', false) + clock.tick(300) + + const primeCall = mvm.eval.getCalls().find( + (c: sinon.SinonSpyCall) => c.args[0] === PRIME_CMD + ) + expect(primeCall).to.be.undefined + }) + }) }) diff --git a/src/test/workspacebrowser/provider/telemetry.test.ts b/src/test/workspacebrowser/provider/telemetry.test.ts new file mode 100644 index 0000000..3b23d2d --- /dev/null +++ b/src/test/workspacebrowser/provider/telemetry.test.ts @@ -0,0 +1,312 @@ +// Copyright 2026 The MathWorks, Inc. + +// Tests for all WSB telemetry events: panel open/close, edit, rename, delete, +// truncation shown, and change-limit clicked. + +import { expect } from 'chai' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { createProviderTestHarness } from './helpers' + +suite('WorkspaceBrowserProvider — telemetry', () => { + teardown(() => { + sinon.restore() + }) + + // ── Panel Open/Close ──────────────────────────────────────────── + + suite('wsbPanelOpened / wsbPanelClosed', () => { + test('logs wsbPanelOpened on resolveWebviewView', () => { + const { telemetryLogger } = createProviderTestHarness() + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbPanelOpened', result: '' } + })).to.be.true + }) + + test('logs wsbPanelClosed on dispose', () => { + const { webviewView, telemetryLogger } = createProviderTestHarness() + telemetryLogger.logEvent.resetHistory() + + const disposeCallback = webviewView.onDidDispose.firstCall.args[0] + disposeCallback() + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbPanelClosed', result: '' } + })).to.be.true + }) + }) + + // ── wsbEditValue ──────────────────────────────────────────────── + + suite('wsbEditValue', () => { + test('logs success when eval completes without error', async () => { + const { webviewHandler, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'editValue', variable: 'x', newValue: '42' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbEditValue', result: 'success' } + })).to.be.true + }) + + test('logs notReady when MATLAB is not connected', async () => { + const { webviewHandler, mvm, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + mvm.getReadyPromise.rejects(new Error('not ready')) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'editValue', variable: 'x', newValue: '1' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbEditValue', result: 'notReady' } + })).to.be.true + }) + + test('logs error when feval returns error response', async () => { + const { webviewHandler, mvm, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + mvm.feval.resolves({ error: { id: 'MATLAB:error', msg: 'bad', status: 'error' } }) + sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'editValue', variable: 'x', newValue: 'bad' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbEditValue', result: 'error' } + })).to.be.true + }) + + test('logs error when feval throws', async () => { + const { webviewHandler, mvm, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + mvm.feval.rejects(new Error('connection lost')) + sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'editValue', variable: 'x', newValue: '1' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbEditValue', result: 'error' } + })).to.be.true + }) + }) + + // ── wsbRenameVariable ─────────────────────────────────────────── + + suite('wsbRenameVariable', () => { + test('logs success when rename completes without error', async () => { + const { webviewHandler, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'renameVariable', variable: 'x', newName: 'y' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbRenameVariable', result: 'success' } + })).to.be.true + }) + + test('logs invalidName when new name is not a valid identifier', async () => { + const { webviewHandler, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'renameVariable', variable: 'x', newName: '123bad' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbRenameVariable', result: 'invalidName' } + })).to.be.true + }) + + test('logs duplicate when new name already exists in cached rows', async () => { + const { serverHandler, webviewHandler, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined) + + // Populate cache with a variable named 'y' + serverHandler({ + type: 'Columns', + columns: [{ name: 'Name', label: 'Name' }, { name: 'Value', label: 'Value' }, { name: 'Size', label: 'Size' }, { name: 'Class', label: 'Class' }] + }) + serverHandler({ + type: 'Data', + data: [{ Name: 'x', Value: '1', Size: '1x1', Class: 'double' }, { Name: 'y', Value: '2', Size: '1x1', Class: 'double' }] + }) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'renameVariable', variable: 'x', newName: 'y' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbRenameVariable', result: 'duplicate' } + })).to.be.true + }) + + test('logs notReady when MATLAB is not connected', async () => { + const { webviewHandler, mvm, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + mvm.getReadyPromise.rejects(new Error('not ready')) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'renameVariable', variable: 'x', newName: 'y' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbRenameVariable', result: 'notReady' } + })).to.be.true + }) + + test('logs error when feval returns error response', async () => { + const { webviewHandler, mvm, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + mvm.feval.resolves({ error: { id: 'MATLAB:error', msg: 'bad', status: 'error' } }) + sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'renameVariable', variable: 'x', newName: 'z' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbRenameVariable', result: 'error' } + })).to.be.true + }) + }) + + // ── wsbDeleteVariable ─────────────────────────────────────────── + + suite('wsbDeleteVariable', () => { + test('logs cancelled when user dismisses the confirmation dialog', async () => { + const { webviewHandler, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'deleteVariable', variable: 'x' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbDeleteVariable', result: 'cancelled' } + })).to.be.true + }) + + test('logs success when delete completes without error', async () => { + const { webviewHandler, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + sinon.stub(vscode.window, 'showWarningMessage').resolves('Delete' as any) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'deleteVariable', variable: 'x' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbDeleteVariable', result: 'success' } + })).to.be.true + }) + + test('logs notReady when MATLAB is not connected', async () => { + const { webviewHandler, mvm, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + sinon.stub(vscode.window, 'showWarningMessage').resolves('Delete' as any) + mvm.getReadyPromise.rejects(new Error('not ready')) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'deleteVariable', variable: 'x' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbDeleteVariable', result: 'notReady' } + })).to.be.true + }) + + test('logs error when feval returns error response', async () => { + const { webviewHandler, mvm, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + sinon.stub(vscode.window, 'showWarningMessage').resolves('Delete' as any) + mvm.feval.resolves({ error: { id: 'MATLAB:error', msg: 'bad', status: 'error' } }) + sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'deleteVariable', variable: 'x' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbDeleteVariable', result: 'error' } + })).to.be.true + }) + }) + + // ── wsbTruncationShown ────────────────────────────────────────── + + suite('wsbTruncationShown', () => { + test('logs when workspace exceeds variable limit', () => { + const { serverHandler, telemetryLogger } = createProviderTestHarness() + sinon.stub(vscode.window, 'showInformationMessage').resolves(undefined) + telemetryLogger.logEvent.resetHistory() + + // Simulate server reporting size > default limit (500) + serverHandler({ type: 'Size', rowCount: 600, columnCount: 4 }) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbTruncationShown', result: '' } + })).to.be.true + }) + + test('does not re-fire on subsequent size updates', () => { + const { serverHandler, telemetryLogger } = createProviderTestHarness() + sinon.stub(vscode.window, 'showInformationMessage').resolves(undefined) + telemetryLogger.logEvent.resetHistory() + + serverHandler({ type: 'Size', rowCount: 600, columnCount: 4 }) + serverHandler({ type: 'Size', rowCount: 700, columnCount: 4 }) + + const truncationCalls = telemetryLogger.logEvent.getCalls().filter( + (c: sinon.SinonSpyCall) => c.args[0]?.data?.action_type === 'wsbTruncationShown' + ) + expect(truncationCalls).to.have.length(1) + }) + }) + + // ── wsbChangeLimitClicked ─────────────────────────────────────── + + suite('wsbChangeLimitClicked', () => { + test('logs when user clicks change-limit via webview message', () => { + const { webviewHandler, telemetryLogger } = createProviderTestHarness({ captureWebviewHandler: true }) + telemetryLogger.logEvent.resetHistory() + + webviewHandler({ type: 'openMaxVariablesSetting' }) + + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbChangeLimitClicked', result: '' } + })).to.be.true + }) + + test('logs when user clicks change-limit via info message toast', async () => { + const { serverHandler, telemetryLogger } = createProviderTestHarness() + const showInfo = sinon.stub(vscode.window, 'showInformationMessage').resolves('Change Maximum Variable Count' as any) + telemetryLogger.logEvent.resetHistory() + + serverHandler({ type: 'Size', rowCount: 600, columnCount: 4 }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(showInfo.calledOnce).to.be.true + expect(telemetryLogger.logEvent.calledWith({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbChangeLimitClicked', result: '' } + })).to.be.true + }) + }) +}) diff --git a/src/test/workspacebrowser/provider/versionGate.test.ts b/src/test/workspacebrowser/provider/versionGate.test.ts index 085c2bb..55be0b5 100644 --- a/src/test/workspacebrowser/provider/versionGate.test.ts +++ b/src/test/workspacebrowser/provider/versionGate.test.ts @@ -58,6 +58,36 @@ suite('WorkspaceBrowserProvider — version gate', () => { }) }) + suite('needsPrime', () => { + test('R2023a needs priming', () => { + expect(WorkspaceBrowserProvider.needsPrime('R2023a')).to.be.true + }) + + test('R2023b needs priming', () => { + expect(WorkspaceBrowserProvider.needsPrime('R2023b')).to.be.true + }) + + test('R2024a does not need priming', () => { + expect(WorkspaceBrowserProvider.needsPrime('R2024a')).to.be.false + }) + + test('R2025a does not need priming', () => { + expect(WorkspaceBrowserProvider.needsPrime('R2025a')).to.be.false + }) + + test('R2022b does not need priming (below minimum)', () => { + expect(WorkspaceBrowserProvider.needsPrime('R2022b')).to.be.false + }) + + test('null release does not need priming', () => { + expect(WorkspaceBrowserProvider.needsPrime(null)).to.be.false + }) + + test('empty string does not need priming', () => { + expect(WorkspaceBrowserProvider.needsPrime('')).to.be.false + }) + }) + suite('getUnsupportedHtml', () => { test('returns HTML mentioning R2023a', () => { const html = getUnsupportedHtml() diff --git a/src/workspacebrowser/WorkspaceBrowserProvider.ts b/src/workspacebrowser/WorkspaceBrowserProvider.ts index 6479054..1147420 100644 --- a/src/workspacebrowser/WorkspaceBrowserProvider.ts +++ b/src/workspacebrowser/WorkspaceBrowserProvider.ts @@ -15,6 +15,9 @@ export const WSB_DEFAULT_MAX_VARIABLES = 500 // Debounce interval for coalescing rapid DataChanged events from the server const DATA_THROTTLE_MS = 300 +// Minimum interval between workspace browser backend refresh commands +const PRIME_THROTTLE_MS = 300 + const MAX_VARS_SETTING_ID = 'MATLAB.maximumWorkspaceVariables' const SORT_METHOD_SETTING_ID = 'MATLAB.workspaceSortMethod' const MAX_VARS_BUTTON_TEXT = 'Change Maximum Variable Count' @@ -45,6 +48,13 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc private dataRequestPending: boolean = false private dataRequestTimer: ReturnType | undefined + // Prevents overlapping backend refresh commands + private primePending: boolean = false + private primeThrottleTimer: ReturnType | undefined + + // Maintains whether the connected MATLAB release requires a refresh after each idle transition + private requiresPrime: boolean = false + // Prevents duplicate max-variables warnings during a single connection session private maxVarsMessageShown: boolean = false @@ -73,6 +83,16 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc }) ) + // MATLAB releases R2023a/b lose workspace change tracking after certain commands. + // Refresh the backend each time MATLAB becomes idle to keep updates flowing. + this.own( + mvm.on(MVM.Events.promptChange, (_state: string, isIdle: boolean) => { + if (isIdle && this.requiresPrime) { + this.schedulePrime() + } + }) + ) + // Re-render icons when the VS Code theme changes this.own( vscode.window.onDidChangeActiveColorTheme(() => { @@ -171,8 +191,10 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc private onMatlabStateChanged (newState: MatlabMVMConnectionState): void { if (newState === MatlabMVMConnectionState.CONNECTED) { + this.requiresPrime = WorkspaceBrowserProvider.needsPrime(this.mvm.getMatlabRelease()) this.onMatlabConnected() } else { + this.requiresPrime = false this.onMatlabDisconnected() } } @@ -208,6 +230,7 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc this.numColumns = 0 this.maxVarsMessageShown = false this.cancelPendingDataRequest() + this.cancelPendingPrime() if (this.view != null) { this.view.webview.html = getDisconnectedHtml() @@ -222,6 +245,11 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc return release >= WSB_MINIMUM_RELEASE } + static needsPrime (release: string | null): boolean { + if (release == null || release === '') return false + return release >= WSB_MINIMUM_RELEASE && release <= 'R2023b' + } + // Begins with a letter, contains only alphanumeric/underscore, max 2048 characters static isValidMatlabIdentifier (name: string): boolean { return name.length > 0 && name.length <= 2048 && (/^[a-zA-Z]\w*$/).test(name) @@ -356,6 +384,10 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc this.handleStateChanged(msg.state as SavedState) break case 'openMaxVariablesSetting': + this.telemetryLogger.logEvent({ + eventKey: 'ML_VS_CODE_ACTIONS', + data: { action_type: 'wsbChangeLimitClicked', result: '' } + }) void vscode.commands.executeCommand('workbench.action.openSettings', MAX_VARS_SETTING_ID) break } @@ -385,6 +417,7 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc try { await this.mvm.getReadyPromise() } catch { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbEditValue', result: 'notReady' } }) this.postToWebview({ type: 'operationError', operation: 'editValue', variable, message: 'MATLAB is not ready' }) return } @@ -392,11 +425,15 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc try { const response = await this.evalInWorkspace(`${variable} = ${newValue};`) if ('error' in response) { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbEditValue', result: 'error' } }) const message = this.extractErrorMessage(response.error) this.postToWebview({ type: 'operationError', operation: 'editValue', variable, message }) this.showMatlabError('editValue', message) + } else { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbEditValue', result: 'success' } }) } } catch (e: unknown) { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbEditValue', result: 'error' } }) const message = e instanceof Error ? e.message : String(e) this.postToWebview({ type: 'operationError', operation: 'editValue', variable, message }) this.showMatlabError('editValue', message) @@ -406,6 +443,7 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc // Rename a variable by assigning to the new name and clearing the old one in the active workspace private async handleRenameVariable (oldName: string, newName: string): Promise { if (!WorkspaceBrowserProvider.isValidMatlabIdentifier(newName)) { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbRenameVariable', result: 'invalidName' } }) const message = `'${newName}' is not a valid MATLAB variable name. ` + 'Names must begin with a letter, contain only letters/digits/underscores, and not exceed 2048 characters.' this.postToWebview({ type: 'operationError', operation: 'rename', variable: oldName, message }) @@ -415,6 +453,7 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc // Client-side duplicate check against cached workspace state if (this.cachedRows?.some((row: WorkspaceVariable) => row.name === newName) === true) { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbRenameVariable', result: 'duplicate' } }) const message = `A variable named "${newName}" already exists` this.postToWebview({ type: 'operationError', operation: 'rename', variable: oldName, message }) this.showMatlabError('rename', message) @@ -424,6 +463,7 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc try { await this.mvm.getReadyPromise() } catch { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbRenameVariable', result: 'notReady' } }) this.postToWebview({ type: 'operationError', operation: 'rename', variable: oldName, message: 'MATLAB is not ready' }) return } @@ -431,11 +471,15 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc try { const response = await this.evalInWorkspace(`${newName} = ${oldName}; clear('${oldName}');`) if ('error' in response) { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbRenameVariable', result: 'error' } }) const message = this.extractErrorMessage(response.error) this.postToWebview({ type: 'operationError', operation: 'rename', variable: oldName, message }) this.showMatlabError('rename', message) + } else { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbRenameVariable', result: 'success' } }) } } catch (e: unknown) { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbRenameVariable', result: 'error' } }) const message = e instanceof Error ? e.message : String(e) this.postToWebview({ type: 'operationError', operation: 'rename', variable: oldName, message }) this.showMatlabError('rename', message) @@ -449,11 +493,15 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc { modal: true }, 'Delete' ) - if (confirmation !== 'Delete') return + if (confirmation !== 'Delete') { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbDeleteVariable', result: 'cancelled' } }) + return + } try { await this.mvm.getReadyPromise() } catch { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbDeleteVariable', result: 'notReady' } }) this.postToWebview({ type: 'operationError', operation: 'delete', variable, message: 'MATLAB is not ready' }) return } @@ -461,11 +509,15 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc try { const response = await this.evalInWorkspace(`clear('${variable}');`) if ('error' in response) { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbDeleteVariable', result: 'error' } }) const message = this.extractErrorMessage(response.error) this.postToWebview({ type: 'operationError', operation: 'delete', variable, message }) this.showMatlabError('delete', message) + } else { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbDeleteVariable', result: 'success' } }) } } catch (e: unknown) { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbDeleteVariable', result: 'error' } }) const message = e instanceof Error ? e.message : String(e) this.postToWebview({ type: 'operationError', operation: 'delete', variable, message }) this.showMatlabError('delete', message) @@ -496,6 +548,27 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc return await this.mvm.feval('evalin', 0, ['base', command], false) } + // ── Prime Throttling ─────────────────────────────────────────────── + + private schedulePrime (): void { + if (this.primePending) return + this.primePending = true + this.primeThrottleTimer = setTimeout(() => { + this.primeThrottleTimer = undefined + void this.mvm.eval('workspace__init__2981022=1;clear workspace__init__2981022;', false).then(() => { + this.primePending = false + }) + }, PRIME_THROTTLE_MS) + } + + private cancelPendingPrime (): void { + if (this.primeThrottleTimer != null) { + clearTimeout(this.primeThrottleTimer) + this.primeThrottleTimer = undefined + } + this.primePending = false + } + // ── Data Request Throttling ────────────────────────────────────── // Coalesces rapid DataChanged events to avoid flooding the server with GetData requests @@ -579,6 +652,7 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc const max = this.getMaxVariableCount() if (this.numRows > max && !this.maxVarsMessageShown) { this.maxVarsMessageShown = true + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbTruncationShown', result: '' } }) const message = MAX_VARIABLES_MESSAGE_TEMPLATE .replace('{currentCount}', this.numRows.toString()) .replace('{maxVariables}', max.toString()) @@ -586,6 +660,7 @@ export default class WorkspaceBrowserProvider extends BaseService implements vsc void vscode.window.showInformationMessage(message, MAX_VARS_BUTTON_TEXT) .then((selection: string | undefined) => { if (selection === MAX_VARS_BUTTON_TEXT) { + this.telemetryLogger.logEvent({ eventKey: 'ML_VS_CODE_ACTIONS', data: { action_type: 'wsbChangeLimitClicked', result: '' } }) void vscode.commands.executeCommand('workbench.action.openSettings', MAX_VARS_SETTING_ID) } }) diff --git a/src/workspacebrowser/icons.ts b/src/workspacebrowser/icons.ts index 911b6c0..bb1efff 100644 --- a/src/workspacebrowser/icons.ts +++ b/src/workspacebrowser/icons.ts @@ -9,6 +9,7 @@ const ICON_MAP: Record = { char: 'wsCharacter.svg', logical: 'wsCheck.svg', duration: 'wsClock.svg', + dictionary: 'wsDataDictionary.svg', datetime: 'wsDate.svg', default: 'wsDefault.svg', categorical: 'wsDots.svg', diff --git a/src/workspacebrowser/resources/icons/dark/wsDataDictionary.svg b/src/workspacebrowser/resources/icons/dark/wsDataDictionary.svg new file mode 100644 index 0000000..522b75b --- /dev/null +++ b/src/workspacebrowser/resources/icons/dark/wsDataDictionary.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/workspacebrowser/resources/icons/light/wsDataDictionary.svg b/src/workspacebrowser/resources/icons/light/wsDataDictionary.svg new file mode 100644 index 0000000..7e6169f --- /dev/null +++ b/src/workspacebrowser/resources/icons/light/wsDataDictionary.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + +