diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 853bc7bdb..47d874e86 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,4 +13,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@v4 - name: 'Dependency Review' - uses: actions/dependency-review-action@v3 + uses: actions/dependency-review-action@v4 diff --git a/README.md b/README.md index 94a578bd3..03b564f5a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The extension is released. You can download it from the [Visual Studio Marketpla To see what has changed in the last few versions of the extension, see the [Changelog](https://github.com/github/vscode-codeql/blob/main/extensions/ql-vscode/CHANGELOG.md). -[![CI status badge](https://github.com/github/vscode-codeql/workflows/Build%20Extension/badge.svg)](https://github.com/github/vscode-codeql/actions?query=workflow%3A%22Build+Extension%22+branch%3Amaster) +[![CI status badge](https://github.com/github/vscode-codeql/workflows/Build%20Extension/badge.svg)](https://github.com/github/vscode-codeql/actions?query=workflow%3A%22Build+Extension%22+branch%3Amain) [![VS Marketplace badge](https://vsmarketplacebadges.dev/version/github.vscode-codeql.svg)](https://marketplace.visualstudio.com/items?itemName=github.vscode-codeql) ## Features @@ -21,13 +21,6 @@ To see what has changed in the last few versions of the extension, see the [Chan This project will track new feature development in CodeQL and, whenever appropriate, bring that functionality to the Visual Studio Code experience. -## Dependencies - -This extension depends on the following two extensions for required functionality. They will be installed automatically when you install VS Code CodeQL. - -- [Test Adapter Converter](https://marketplace.visualstudio.com/items?itemName=ms-vscode.test-adapter-converter) -- [Test Explorer UI](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer) - ## Contributing This project welcomes contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to build, install, and contribute. diff --git a/extensions/ql-vscode/.eslintrc.js b/extensions/ql-vscode/.eslintrc.js index b930339ad..5621731a1 100644 --- a/extensions/ql-vscode/.eslintrc.js +++ b/extensions/ql-vscode/.eslintrc.js @@ -47,17 +47,12 @@ const baseConfig = { "@typescript-eslint/no-throw-literal": "error", "@typescript-eslint/consistent-type-imports": "error", "import/consistent-type-specifier-style": ["error", "prefer-top-level"], - "no-useless-escape": 0, - camelcase: "off", curly: ["error", "all"], "escompat/no-regexp-lookbehind": "off", "etc/no-implicit-any-catch": "error", "filenames/match-regex": "off", - "filenames/match-regexp": "off", - "func-style": "off", "i18n-text/no-en": "off", "no-invalid-this": "off", - "no-fallthrough": "off", "no-console": "off", "no-shadow": "off", "github/array-foreach": "off", diff --git a/extensions/ql-vscode/CHANGELOG.md b/extensions/ql-vscode/CHANGELOG.md index 5f3260f5c..9a4f3c532 100644 --- a/extensions/ql-vscode/CHANGELOG.md +++ b/extensions/ql-vscode/CHANGELOG.md @@ -2,10 +2,20 @@ ## [UNRELEASED] +- Enable collection of telemetry for the `codeQL.addingDatabases.addDatabaseSourceToWorkspace` setting. [#3238](https://github.com/github/vscode-codeql/pull/3238) +- In the CodeQL model editor, you can now select individual method rows and save changes to only the selected rows, instead of having to save the entire library model. [#3156](https://github.com/github/vscode-codeql/pull/3156) +- If you run a query without having selected a database, we show a more intuitive prompt to help you select a database. [#3214](https://github.com/github/vscode-codeql/pull/3214) +- The UI for browsing and running CodeQL tests has moved to use VS Code's built-in test UI. This makes the CodeQL test UI more consistent with the test UIs for other languages. + This change means that this extension no longer depends on the "Test Explorer UI" and "Test Adapter Converter" extensions. You can uninstall those two extensions if they are + not being used by any other extensions you may have installed. + +## 1.12.0 - 11 January 2024 + - Add a prompt for downloading a GitHub database when opening a GitHub repository. [#3138](https://github.com/github/vscode-codeql/pull/3138) - Avoid showing a popup when hovering over source elements in database source files. [#3125](https://github.com/github/vscode-codeql/pull/3125) - Add comparison of alerts when comparing query results. This allows viewing path explanations for differences in alerts. [#3113](https://github.com/github/vscode-codeql/pull/3113) - Fix a bug where the CodeQL CLI and variant analysis results were corrupted after extraction in VS Code Insiders. [#3151](https://github.com/github/vscode-codeql/pull/3151) & [#3152](https://github.com/github/vscode-codeql/pull/3152) +- Show progress when extracting the CodeQL CLI distribution during installation. [#3157](https://github.com/github/vscode-codeql/pull/3157) - Add option to cancel opening the model editor. [#3189](https://github.com/github/vscode-codeql/pull/3189) ## 1.11.0 - 13 December 2023 diff --git a/extensions/ql-vscode/README.md b/extensions/ql-vscode/README.md index 7558d27cf..91c0016fc 100644 --- a/extensions/ql-vscode/README.md +++ b/extensions/ql-vscode/README.md @@ -17,8 +17,6 @@ For information about other configurations, see the separate [CodeQL help](https ### Quick start: Installing and configuring the extension 1. [Install the extension](#installing-the-extension). - *Note: vscode-codeql installs the following dependencies for required functionality: [Test Adapter Converter](https://marketplace.visualstudio.com/items?itemName=ms-vscode.test-adapter-converter), [Test Explorer UI](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer).* - 1. [Check access to the CodeQL CLI](#checking-access-to-the-codeql-cli). 1. [Clone the CodeQL starter workspace](#cloning-the-codeql-starter-workspace). diff --git a/extensions/ql-vscode/package-lock.json b/extensions/ql-vscode/package-lock.json index 87d192576..71375505e 100644 --- a/extensions/ql-vscode/package-lock.json +++ b/extensions/ql-vscode/package-lock.json @@ -1,15 +1,16 @@ { "name": "vscode-codeql", - "version": "1.11.1", + "version": "1.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-codeql", - "version": "1.11.1", + "version": "1.12.1", "hasInstallScript": true, "license": "MIT", "dependencies": { + "@floating-ui/react": "^0.26.5", "@octokit/plugin-retry": "^6.0.1", "@octokit/rest": "^20.0.2", "@vscode/codicons": "^0.0.35", @@ -23,7 +24,7 @@ "d3-graphviz": "^5.0.2", "fs-extra": "^11.1.1", "js-yaml": "^4.1.0", - "msw": "^2.0.11", + "msw": "^2.0.13", "nanoid": "^5.0.1", "node-fetch": "^2.6.7", "p-queue": "^8.0.1", @@ -40,8 +41,6 @@ "vscode-extension-telemetry": "^0.1.6", "vscode-jsonrpc": "^8.0.2", "vscode-languageclient": "^8.0.2", - "vscode-test-adapter-api": "^1.7.0", - "vscode-test-adapter-util": "^0.7.0", "yauzl": "^2.10.0", "zip-a-folder": "^3.1.3" }, @@ -55,18 +54,18 @@ "@github/markdownlint-github": "^0.6.0", "@octokit/plugin-throttling": "^8.0.0", "@playwright/test": "^1.40.1", - "@storybook/addon-a11y": "^7.4.6", + "@storybook/addon-a11y": "^7.6.9", "@storybook/addon-actions": "^7.1.0", "@storybook/addon-essentials": "^7.1.0", "@storybook/addon-interactions": "^7.1.0", "@storybook/addon-links": "^7.1.0", "@storybook/components": "^7.6.7", "@storybook/csf": "^0.1.1", - "@storybook/manager-api": "^7.6.6", + "@storybook/manager-api": "^7.6.7", "@storybook/react": "^7.1.0", "@storybook/react-webpack5": "^7.6.7", - "@storybook/theming": "^7.6.7", - "@testing-library/dom": "^9.3.0", + "@storybook/theming": "^7.6.9", + "@testing-library/dom": "^9.3.4", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.5.2", @@ -94,15 +93,15 @@ "@types/unzipper": "^0.10.1", "@types/vscode": "^1.82.0", "@types/yauzl": "^2.10.3", - "@typescript-eslint/eslint-plugin": "^6.18.0", + "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.16.0", "@vscode/test-electron": "^2.2.0", "@vscode/vsce": "^2.19.0", "ansi-colors": "^4.1.1", - "applicationinsights": "^2.3.5", + "applicationinsights": "^2.9.2", "cosmiconfig": "^9.0.0", "cross-env": "^7.0.3", - "css-loader": "^6.8.1", + "css-loader": "^6.9.0", "del": "^6.0.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.0.0", @@ -111,7 +110,7 @@ "eslint-plugin-github": "^4.4.1", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest-dom": "^5.0.1", - "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.31.8", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^0.6.4", @@ -131,7 +130,7 @@ "mini-css-extract-plugin": "^2.6.1", "npm-run-all": "^4.1.5", "patch-package": "^8.0.0", - "prettier": "^3.0.0", + "prettier": "^3.2.4", "storybook": "^7.6.7", "tar-stream": "^3.0.0", "through2": "^4.0.2", @@ -2968,42 +2967,57 @@ "dev": true }, "node_modules/@floating-ui/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz", - "integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==", - "dev": true, + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.3.tgz", + "integrity": "sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q==", "dependencies": { - "@floating-ui/utils": "^0.1.3" + "@floating-ui/utils": "^0.2.0" } }, "node_modules/@floating-ui/dom": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", - "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", - "dev": true, + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.4.tgz", + "integrity": "sha512-jByEsHIY+eEdCjnTVu+E3ephzTOzkQ8hgUfGwos+bg7NlH33Zc5uO+QHz1mrQUOgIKKDD1RtS201P9NvAfq3XQ==", "dependencies": { - "@floating-ui/core": "^1.4.2", - "@floating-ui/utils": "^0.1.3" + "@floating-ui/core": "^1.5.3", + "@floating-ui/utils": "^0.2.0" } }, - "node_modules/@floating-ui/react-dom": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz", - "integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==", - "dev": true, + "node_modules/@floating-ui/react": { + "version": "0.26.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.5.tgz", + "integrity": "sha512-LJeSQa+yOwV0Tdpc/C3Vr92QMrwRqRMTk4yOwsRJKc57x3Lcw317GE0EV+ECM7+Z89yEAPBe7nzbDEWfkWCrBA==", "dependencies": { - "@floating-ui/dom": "^1.5.1" + "@floating-ui/react-dom": "^2.0.5", + "@floating-ui/utils": "^0.2.0", + "tabbable": "^6.0.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.5.tgz", + "integrity": "sha512-UsBK30Bg+s6+nsgblXtZmwHhgS2vmbuQK22qgt2pTQM6M3X6H1+cQcLXqgRY3ihVLcZJE6IvqDQozhsnIVqK/Q==", + "dependencies": { + "@floating-ui/dom": "^1.5.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react/node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/@floating-ui/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==", - "dev": true + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" }, "node_modules/@github/browserslist-config": { "version": "1.0.0", @@ -3989,9 +4003,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.25.13", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.13.tgz", - "integrity": "sha512-xfjR81WwXPHwhDbqJRHlxYmboJuiSaIKpP4I5TJVFl/EmByOU13jOBT9hmEnxcjR3jvFYoqoNKt7MM9uqerj9A==", + "version": "0.25.14", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.14.tgz", + "integrity": "sha512-2dnIxl+obqIqjoPXTFldhe6pcdOrqiz+GcLaQQ6hmL02OldAF7nIC+rUgTWm+iF6lvmyCVhFFqbgbapNhR8eag==", "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", @@ -4266,12 +4280,12 @@ } }, "node_modules/@opentelemetry/core": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.18.1.tgz", - "integrity": "sha512-kvnUqezHMhsQvdsnhnqTNfAJs3ox/isB0SVrM1dhVFw7SsB7TstuVa6fgWnN2GdPyilIFLUvvbTZoVRmx6eiRg==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.19.0.tgz", + "integrity": "sha512-w42AukJh3TP8R0IZZOVJVM/kMWu8g+lm4LzT70WtuKqhwq7KVhcDzZZuZinWZa6TtQCl7Smt2wolEYzpHabOgw==", "dev": true, "dependencies": { - "@opentelemetry/semantic-conventions": "1.18.1" + "@opentelemetry/semantic-conventions": "1.19.0" }, "engines": { "node": ">=14" @@ -4300,13 +4314,13 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.18.1.tgz", - "integrity": "sha512-JjbcQLYMttXcIabflLRuaw5oof5gToYV9fuXbcsoOeQ0BlbwUn6DAZi++PNsSz2jjPeASfDls10iaO/8BRIPRA==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.19.0.tgz", + "integrity": "sha512-RgxvKuuMOf7nctOeOvpDjt2BpZvZGr9Y0vf7eGtY5XYZPkh2p7e2qub1S2IArdBMf9kEbz0SfycqCviOu9isqg==", "dev": true, "dependencies": { - "@opentelemetry/core": "1.18.1", - "@opentelemetry/semantic-conventions": "1.18.1" + "@opentelemetry/core": "1.19.0", + "@opentelemetry/semantic-conventions": "1.19.0" }, "engines": { "node": ">=14" @@ -4316,14 +4330,14 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.18.1.tgz", - "integrity": "sha512-tRHfDxN5dO+nop78EWJpzZwHsN1ewrZRVVwo03VJa3JQZxToRDH29/+MB24+yoa+IArerdr7INFJiX/iN4gjqg==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.19.0.tgz", + "integrity": "sha512-+IRvUm+huJn2KqfFW3yW/cjvRwJ8Q7FzYHoUNx5Fr0Lws0LxjMJG1uVB8HDpLwm7mg5XXH2M5MF+0jj5cM8BpQ==", "dev": true, "dependencies": { - "@opentelemetry/core": "1.18.1", - "@opentelemetry/resources": "1.18.1", - "@opentelemetry/semantic-conventions": "1.18.1" + "@opentelemetry/core": "1.19.0", + "@opentelemetry/resources": "1.19.0", + "@opentelemetry/semantic-conventions": "1.19.0" }, "engines": { "node": ">=14" @@ -4333,9 +4347,9 @@ } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.18.1.tgz", - "integrity": "sha512-+NLGHr6VZwcgE/2lw8zDIufOCGnzsA5CbQIMleXZTrgkBd0TanCX+MiDYJ1TOS4KL/Tqk0nFRxawnaYr6pkZkA==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.19.0.tgz", + "integrity": "sha512-14jRpC8f5c0gPSwoZ7SbEJni1PqI+AhAE8m1bMz6v+RPM4OlP1PT2UHBJj5Qh/ALLPjhVU/aZUK3YyjTUqqQVg==", "dev": true, "engines": { "node": ">=14" @@ -5194,12 +5208,12 @@ } }, "node_modules/@storybook/addon-a11y": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-7.6.4.tgz", - "integrity": "sha512-NwROJMes3D1WVPSmASVnHtDCIFQCF3DoPJEpzpnUYFxCR2IQzqPSlf9jptRkot6XjL5XwVX5mV2KxC2lKA1cfg==", + "version": "7.6.9", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-7.6.9.tgz", + "integrity": "sha512-TMr6X8rd0qIAoNHP4Sb+YcQR13lB1uEWNv7lFJc7lcZVUV9ZtxqrQ3lbZDrPaTU1rpBoWXNUGMKjd6Teq9WDAw==", "dev": true, "dependencies": { - "@storybook/addon-highlight": "7.6.4", + "@storybook/addon-highlight": "7.6.9", "axe-core": "^4.2.0" }, "funding": { @@ -5207,6 +5221,19 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/addon-a11y/node_modules/@storybook/addon-highlight": { + "version": "7.6.9", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-7.6.9.tgz", + "integrity": "sha512-TgfUdZqG+X6lJfZJvMkSsG+UfiEkh1xAXC628RTcVjVx5uopuezdASLU2jpLbyBc9Dmvp3j0XaEJph50n9sEUQ==", + "dev": true, + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, "node_modules/@storybook/addon-actions": { "version": "7.6.4", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-7.6.4.tgz", @@ -6622,6 +6649,26 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/components/node_modules/@storybook/theming": { + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-7.6.7.tgz", + "integrity": "sha512-+42rfC4rZtWVAXJ7JBUQKnQ6vWBXJVHZ9HtNUWzQLPR9sJSMmHnnSMV6y5tizGgZqmBnAIkuoYk+Tt6NfwUmSA==", + "dev": true, + "dependencies": { + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", + "@storybook/client-logger": "7.6.7", + "@storybook/global": "^5.0.0", + "memoizerific": "^1.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@storybook/components/node_modules/@storybook/types": { "version": "7.6.7", "resolved": "https://registry.npmjs.org/@storybook/types/-/types-7.6.7.tgz", @@ -7381,23 +7428,22 @@ } }, "node_modules/@storybook/manager-api": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-7.6.6.tgz", - "integrity": "sha512-euRAbSZAUzHDt6z1Pq/g45N/RNqta9RaQAym18zt/oLWiYOIrkLmdf7kCuFYsmuA5XQBytiJqwkAD7uF1aLe0g==", + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-7.6.7.tgz", + "integrity": "sha512-3Wk/BvuGUlw/X05s57zZO7gJbzfUeE9Xe+CSIvuH7RY5jx9PYnNwqNlTXPXhJ5LPvwMthae7WJVn3SuBpbptoQ==", "dev": true, "dependencies": { - "@storybook/channels": "7.6.6", - "@storybook/client-logger": "7.6.6", - "@storybook/core-events": "7.6.6", + "@storybook/channels": "7.6.7", + "@storybook/client-logger": "7.6.7", + "@storybook/core-events": "7.6.7", "@storybook/csf": "^0.1.2", "@storybook/global": "^5.0.0", - "@storybook/router": "7.6.6", - "@storybook/theming": "7.6.6", - "@storybook/types": "7.6.6", + "@storybook/router": "7.6.7", + "@storybook/theming": "7.6.7", + "@storybook/types": "7.6.7", "dequal": "^2.0.2", "lodash": "^4.17.21", "memoizerific": "^1.11.3", - "semver": "^7.3.7", "store2": "^2.14.2", "telejson": "^7.2.0", "ts-dedent": "^2.0.0" @@ -7408,13 +7454,13 @@ } }, "node_modules/@storybook/manager-api/node_modules/@storybook/channels": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.6.6.tgz", - "integrity": "sha512-vvo7fBe2WffPonNNOA7Xx7jcHAto8qJYlq+VMysfheXrsRRbhHl3WQOA18Vm8hV9txtqdqk0hwQiXOWvhYVpeQ==", + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.6.7.tgz", + "integrity": "sha512-u1hURhfQHHtZyRIDUENRCp+CRRm7IQfcjQaoWI06XCevQPuhVEtFUfXHjG+J74aA/JuuTLFUtqwNm1zGqbXTAQ==", "dev": true, "dependencies": { - "@storybook/client-logger": "7.6.6", - "@storybook/core-events": "7.6.6", + "@storybook/client-logger": "7.6.7", + "@storybook/core-events": "7.6.7", "@storybook/global": "^5.0.0", "qs": "^6.10.0", "telejson": "^7.2.0", @@ -7426,9 +7472,9 @@ } }, "node_modules/@storybook/manager-api/node_modules/@storybook/client-logger": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.6.tgz", - "integrity": "sha512-WEvVyuQR5oNF8jcMmGA13zDjxP/l46kOBBvB6JSc8toUdtLZ/kZWSnU0ioNM8+ECpFqXHjBcF2K6uSJOEb6YEg==", + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.7.tgz", + "integrity": "sha512-A16zpWgsa0gSdXMR9P3bWVdC9u/1B1oG4H7Z1+JhNzgnL3CdyOYO0qFSiAtNBso4nOjIAJVb6/AoBzdRhmSVQg==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0" @@ -7439,9 +7485,9 @@ } }, "node_modules/@storybook/manager-api/node_modules/@storybook/core-events": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.6.6.tgz", - "integrity": "sha512-7+q9HiZiLxaQcwpaSLQrLdjHNHBoOoUY9ZcZXI9iNFSopOgb/ItDnzzlpv08NC7CbKae1hVKJM/t5aSTl7tCMw==", + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.6.7.tgz", + "integrity": "sha512-KZ5d03c47pnr5/kY26pJtWq7WpmCPXLbgyjJZDSc+TTY153BdZksvlBXRHtqM1yj2UM6QsSyIuiJaADJNAbP2w==", "dev": true, "dependencies": { "ts-dedent": "^2.0.0" @@ -7452,13 +7498,13 @@ } }, "node_modules/@storybook/manager-api/node_modules/@storybook/theming": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-7.6.6.tgz", - "integrity": "sha512-hNZOOxaF55iAGUEM0dvAIP6LfGMgPKCJQIk/qyotFk+SKkg3PBqzph89XfFl9yCD3KiX5cryqarULgVuNawLJg==", + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-7.6.7.tgz", + "integrity": "sha512-+42rfC4rZtWVAXJ7JBUQKnQ6vWBXJVHZ9HtNUWzQLPR9sJSMmHnnSMV6y5tizGgZqmBnAIkuoYk+Tt6NfwUmSA==", "dev": true, "dependencies": { "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", - "@storybook/client-logger": "7.6.6", + "@storybook/client-logger": "7.6.7", "@storybook/global": "^5.0.0", "memoizerific": "^1.11.3" }, @@ -7472,12 +7518,12 @@ } }, "node_modules/@storybook/manager-api/node_modules/@storybook/types": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@storybook/types/-/types-7.6.6.tgz", - "integrity": "sha512-77vbQp3GX93OD8UzFkY4a0fAmkZrqLe61XVo6yABrwbVDY0EcAwaCF5gcXRhOHldlH7KYbLfEQkDkkKTBjX7ow==", + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@storybook/types/-/types-7.6.7.tgz", + "integrity": "sha512-VcGwrI4AkBENxkoAUJ+Z7SyMK73hpoY0TTtw2J7tc05/xdiXhkQTX15Qa12IBWIkoXCyNrtaU+q7KR8Tjzi+uw==", "dev": true, "dependencies": { - "@storybook/channels": "7.6.6", + "@storybook/channels": "7.6.7", "@types/babel__core": "^7.0.0", "@types/express": "^4.7.0", "file-system-cache": "2.3.0" @@ -8125,12 +8171,12 @@ } }, "node_modules/@storybook/router": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@storybook/router/-/router-7.6.6.tgz", - "integrity": "sha512-dkn81MtxrG7JMDbOHEcVZkTDVKsneg72CyqJ8ELZfC81iKQcDMQkV9mdmnMl45aKn6UrscudI4K23OxQmsevkw==", + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@storybook/router/-/router-7.6.7.tgz", + "integrity": "sha512-kkhNSdC3fXaQxILg8a26RKk4/ZbF/AUVrepUEyO8lwvbJ6LItTyWSE/4I9Ih4qV2Mjx33ncc8vLqM9p8r5qnMA==", "dev": true, "dependencies": { - "@storybook/client-logger": "7.6.6", + "@storybook/client-logger": "7.6.7", "memoizerific": "^1.11.3", "qs": "^6.10.0" }, @@ -8140,9 +8186,9 @@ } }, "node_modules/@storybook/router/node_modules/@storybook/client-logger": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.6.tgz", - "integrity": "sha512-WEvVyuQR5oNF8jcMmGA13zDjxP/l46kOBBvB6JSc8toUdtLZ/kZWSnU0ioNM8+ECpFqXHjBcF2K6uSJOEb6YEg==", + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.7.tgz", + "integrity": "sha512-A16zpWgsa0gSdXMR9P3bWVdC9u/1B1oG4H7Z1+JhNzgnL3CdyOYO0qFSiAtNBso4nOjIAJVb6/AoBzdRhmSVQg==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0" @@ -8351,13 +8397,13 @@ } }, "node_modules/@storybook/theming": { - "version": "7.6.7", - "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-7.6.7.tgz", - "integrity": "sha512-+42rfC4rZtWVAXJ7JBUQKnQ6vWBXJVHZ9HtNUWzQLPR9sJSMmHnnSMV6y5tizGgZqmBnAIkuoYk+Tt6NfwUmSA==", + "version": "7.6.9", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-7.6.9.tgz", + "integrity": "sha512-S2tow/l2HJFL7im+ovFQE0nLCzy/39qZU30/WVc8gM2dfM7Gsn6M4xiXu23BEwJHnCP8TIOBiCDN1JkOcOvvgg==", "dev": true, "dependencies": { "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", - "@storybook/client-logger": "7.6.7", + "@storybook/client-logger": "7.6.9", "@storybook/global": "^5.0.0", "memoizerific": "^1.11.3" }, @@ -8371,9 +8417,9 @@ } }, "node_modules/@storybook/theming/node_modules/@storybook/client-logger": { - "version": "7.6.7", - "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.7.tgz", - "integrity": "sha512-A16zpWgsa0gSdXMR9P3bWVdC9u/1B1oG4H7Z1+JhNzgnL3CdyOYO0qFSiAtNBso4nOjIAJVb6/AoBzdRhmSVQg==", + "version": "7.6.9", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.9.tgz", + "integrity": "sha512-Xm6fa6AR3cjxabauMldBv/66OOp5IhDiUEpp4D/a7hXfvCWqwmjVJ6EPz9WzkMhcPbMJr8vWJBaS3glkFqsRng==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0" @@ -8610,9 +8656,9 @@ "dev": true }, "node_modules/@testing-library/dom": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", - "integrity": "sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==", + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", "dev": true, "dependencies": { "@babel/code-frame": "^7.10.4", @@ -9862,16 +9908,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.18.0.tgz", - "integrity": "sha512-3lqEvQUdCozi6d1mddWqd+kf8KxmGq2Plzx36BlkjuQe3rSTm/O98cLf0A4uDO+a5N1KD2SeEEl6fW97YHY+6w==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.19.0.tgz", + "integrity": "sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.18.0", - "@typescript-eslint/type-utils": "6.18.0", - "@typescript-eslint/utils": "6.18.0", - "@typescript-eslint/visitor-keys": "6.18.0", + "@typescript-eslint/scope-manager": "6.19.0", + "@typescript-eslint/type-utils": "6.19.0", + "@typescript-eslint/utils": "6.19.0", + "@typescript-eslint/visitor-keys": "6.19.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -9897,13 +9943,13 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.18.0.tgz", - "integrity": "sha512-o/UoDT2NgOJ2VfHpfr+KBY2ErWvCySNUIX/X7O9g8Zzt/tXdpfEU43qbNk8LVuWUT2E0ptzTWXh79i74PP0twA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", + "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.18.0", - "@typescript-eslint/visitor-keys": "6.18.0" + "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/visitor-keys": "6.19.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -9914,9 +9960,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.18.0.tgz", - "integrity": "sha512-/RFVIccwkwSdW/1zeMx3hADShWbgBxBnV/qSrex6607isYjj05t36P6LyONgqdUrNLl5TYU8NIKdHUYpFvExkA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", + "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -9927,12 +9973,12 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.18.0.tgz", - "integrity": "sha512-1wetAlSZpewRDb2h9p/Q8kRjdGuqdTAQbkJIOUMLug2LBLG+QOjiWoSj6/3B/hA9/tVTFFdtiKvAYoYnSRW/RA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", + "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.18.0", + "@typescript-eslint/types": "6.19.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -10130,13 +10176,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.18.0.tgz", - "integrity": "sha512-ZeMtrXnGmTcHciJN1+u2CigWEEXgy1ufoxtWcHORt5kGvpjjIlK9MUhzHm4RM8iVy6dqSaZA/6PVkX6+r+ChjQ==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.0.tgz", + "integrity": "sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.18.0", - "@typescript-eslint/utils": "6.18.0", + "@typescript-eslint/typescript-estree": "6.19.0", + "@typescript-eslint/utils": "6.19.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -10157,9 +10203,9 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.18.0.tgz", - "integrity": "sha512-/RFVIccwkwSdW/1zeMx3hADShWbgBxBnV/qSrex6607isYjj05t36P6LyONgqdUrNLl5TYU8NIKdHUYpFvExkA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", + "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -10170,13 +10216,13 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.18.0.tgz", - "integrity": "sha512-klNvl+Ql4NsBNGB4W9TZ2Od03lm7aGvTbs0wYaFYsplVPhr+oeXjlPZCDI4U9jgJIDK38W1FKhacCFzCC+nbIg==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", + "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.18.0", - "@typescript-eslint/visitor-keys": "6.18.0", + "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/visitor-keys": "6.19.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -10198,12 +10244,12 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.18.0.tgz", - "integrity": "sha512-1wetAlSZpewRDb2h9p/Q8kRjdGuqdTAQbkJIOUMLug2LBLG+QOjiWoSj6/3B/hA9/tVTFFdtiKvAYoYnSRW/RA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", + "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.18.0", + "@typescript-eslint/types": "6.19.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -10304,17 +10350,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.18.0.tgz", - "integrity": "sha512-wiKKCbUeDPGaYEYQh1S580dGxJ/V9HI7K5sbGAVklyf+o5g3O+adnS4UNJajplF4e7z2q0uVBaTdT/yLb4XAVA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.0.tgz", + "integrity": "sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.18.0", - "@typescript-eslint/types": "6.18.0", - "@typescript-eslint/typescript-estree": "6.18.0", + "@typescript-eslint/scope-manager": "6.19.0", + "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/typescript-estree": "6.19.0", "semver": "^7.5.4" }, "engines": { @@ -10329,13 +10375,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.18.0.tgz", - "integrity": "sha512-o/UoDT2NgOJ2VfHpfr+KBY2ErWvCySNUIX/X7O9g8Zzt/tXdpfEU43qbNk8LVuWUT2E0ptzTWXh79i74PP0twA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", + "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.18.0", - "@typescript-eslint/visitor-keys": "6.18.0" + "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/visitor-keys": "6.19.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -10346,9 +10392,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.18.0.tgz", - "integrity": "sha512-/RFVIccwkwSdW/1zeMx3hADShWbgBxBnV/qSrex6607isYjj05t36P6LyONgqdUrNLl5TYU8NIKdHUYpFvExkA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", + "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -10359,13 +10405,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.18.0.tgz", - "integrity": "sha512-klNvl+Ql4NsBNGB4W9TZ2Od03lm7aGvTbs0wYaFYsplVPhr+oeXjlPZCDI4U9jgJIDK38W1FKhacCFzCC+nbIg==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", + "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.18.0", - "@typescript-eslint/visitor-keys": "6.18.0", + "@typescript-eslint/types": "6.19.0", + "@typescript-eslint/visitor-keys": "6.19.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -10387,12 +10433,12 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.18.0.tgz", - "integrity": "sha512-1wetAlSZpewRDb2h9p/Q8kRjdGuqdTAQbkJIOUMLug2LBLG+QOjiWoSj6/3B/hA9/tVTFFdtiKvAYoYnSRW/RA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", + "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.18.0", + "@typescript-eslint/types": "6.19.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -11072,9 +11118,9 @@ } }, "node_modules/applicationinsights": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.1.tgz", - "integrity": "sha512-hrpe/OvHFZlq+SQERD1fxaYICyunxzEBh9SolJebzYnIXkyA9zxIR87dZAh+F3+weltbqdIP8W038cvtpMNhQg==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.2.tgz", + "integrity": "sha512-wlDiD7v0BQNM8oNzsf9C836R5ze25u+CuCEZsbA5xMIXYYBxkqkWE/mo9GFJM7rsKaiGqpxEwWmePHKD2Lwy2w==", "dev": true, "dependencies": { "@azure/core-auth": "^1.5.0", @@ -11082,14 +11128,14 @@ "@azure/core-util": "1.2.0", "@azure/opentelemetry-instrumentation-azure-sdk": "^1.0.0-beta.5", "@microsoft/applicationinsights-web-snippet": "^1.0.1", - "@opentelemetry/api": "^1.4.1", - "@opentelemetry/core": "^1.15.2", - "@opentelemetry/sdk-trace-base": "^1.15.2", - "@opentelemetry/semantic-conventions": "^1.15.2", + "@opentelemetry/api": "^1.7.0", + "@opentelemetry/core": "^1.19.0", + "@opentelemetry/sdk-trace-base": "^1.19.0", + "@opentelemetry/semantic-conventions": "^1.19.0", "cls-hooked": "^4.2.2", "continuation-local-storage": "^3.2.1", "diagnostic-channel": "1.1.1", - "diagnostic-channel-publishers": "1.0.7" + "diagnostic-channel-publishers": "1.0.8" }, "engines": { "node": ">=8.0.0" @@ -13711,19 +13757,19 @@ } }, "node_modules/css-loader": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", - "integrity": "sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.9.0.tgz", + "integrity": "sha512-3I5Nu4ytWlHvOP6zItjiHlefBNtrH+oehq8tnQa2kO305qpVyx9XNIT1CXIj5bgCJs7qICBCkgCYxQLKPANoLA==", "dev": true, "dependencies": { "icss-utils": "^5.1.0", - "postcss": "^8.4.21", + "postcss": "^8.4.31", "postcss-modules-extract-imports": "^3.0.0", "postcss-modules-local-by-default": "^4.0.3", - "postcss-modules-scope": "^3.0.0", + "postcss-modules-scope": "^3.1.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", - "semver": "^7.3.8" + "semver": "^7.5.4" }, "engines": { "node": ">= 12.13.0" @@ -14802,9 +14848,9 @@ } }, "node_modules/diagnostic-channel-publishers": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/diagnostic-channel-publishers/-/diagnostic-channel-publishers-1.0.7.tgz", - "integrity": "sha512-SEECbY5AiVt6DfLkhkaHNeshg1CogdLLANA8xlG/TKvS+XUgvIKl7VspJGYiEdL5OUyzMVnr7o0AwB7f+/Mjtg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/diagnostic-channel-publishers/-/diagnostic-channel-publishers-1.0.8.tgz", + "integrity": "sha512-HmSm9hXxSPxA9BaLGY98QU1zsdjeCk113KjAYGPCen1ZP6mhVaTPzHd6UYv5r21DnWANi+f+NyPOHruGT9jpqQ==", "dev": true, "peerDependencies": { "diagnostic-channel": "*" @@ -15874,23 +15920,24 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz", - "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", + "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", "dev": true, "dependencies": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.8.5" + "synckit": "^0.8.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/prettier" + "url": "https://opencollective.com/eslint-plugin-prettier" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", + "eslint-config-prettier": "*", "prettier": ">=3.0.0" }, "peerDependenciesMeta": { @@ -25033,16 +25080,16 @@ "dev": true }, "node_modules/msw": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.0.11.tgz", - "integrity": "sha512-dAXFS2DxZX0uFqMPhS3oUAu8S/5IQ5qKKSwtXl3/dMTeML0C8JfSvbeWtowYg6pu4Iehgp5L/pHLrlIcG++y/A==", + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.0.13.tgz", + "integrity": "sha512-FN4GUOTxm+cucXsFFNIZooHWNGGGRZCa5HxcrbdPxSIZMmGkPW2XewidZPcQn6AXO5SisZtfijXFGDlme/BbUw==", "hasInstallScript": true, "dependencies": { "@bundled-es-modules/cookie": "^2.0.0", "@bundled-es-modules/js-levenshtein": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.25.13", + "@mswjs/interceptors": "^0.25.14", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.4.1", "@types/js-levenshtein": "^1.1.1", @@ -25071,7 +25118,7 @@ "url": "https://opencollective.com/mswjs" }, "peerDependencies": { - "typescript": ">= 4.7.x <= 5.2.x" + "typescript": ">= 4.7.x <= 5.3.x" }, "peerDependenciesMeta": { "typescript": { @@ -26166,9 +26213,9 @@ } }, "node_modules/outvariant": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.0.tgz", - "integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==" + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.2.tgz", + "integrity": "sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==" }, "node_modules/p-limit": { "version": "3.1.0", @@ -26937,9 +26984,9 @@ } }, "node_modules/postcss-modules-scope": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", - "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.1.0.tgz", + "integrity": "sha512-SaIbK8XW+MZbd0xHPf7kdfA/3eOt7vxJ72IRecn3EzuZVLr1r0orzf0MX/pN8m+NMDoo6X/SQd8oeKqGZd8PXg==", "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.4" @@ -27038,9 +27085,9 @@ } }, "node_modules/prettier": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", - "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz", + "integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -32044,31 +32091,6 @@ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz", "integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==" }, - "node_modules/vscode-test-adapter-api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/vscode-test-adapter-api/-/vscode-test-adapter-api-1.9.0.tgz", - "integrity": "sha512-lltjehUP0J9H3R/HBctjlqeUCwn2t9Lbhj2Y500ib+j5Y4H3hw+hVTzuSsfw16LtxY37knlU39QIlasa7svzOQ==", - "engines": { - "vscode": "^1.23.0" - } - }, - "node_modules/vscode-test-adapter-util": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/vscode-test-adapter-util/-/vscode-test-adapter-util-0.7.1.tgz", - "integrity": "sha512-OZZvLDDNhayVVISyTmgUntOhMzl6j9/wVGfNqI2zuR5bQIziTQlDs9W29dFXDTGXZOxazS6uiHkrr86BKDzYUA==", - "dependencies": { - "tslib": "^1.11.1", - "vscode-test-adapter-api": "^1.8.0" - }, - "engines": { - "vscode": "^1.24.0" - } - }, - "node_modules/vscode-test-adapter-util/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index 7790d031b..1c4644526 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -4,7 +4,7 @@ "description": "CodeQL for Visual Studio Code", "author": "GitHub", "private": true, - "version": "1.11.1", + "version": "1.12.1", "publisher": "GitHub", "license": "MIT", "icon": "media/VS-marketplace-CodeQL-icon.png", @@ -21,7 +21,6 @@ "Programming Languages" ], "extensionDependencies": [ - "hbenl.vscode-test-explorer", "vscode.git" ], "capabilities": { @@ -546,6 +545,14 @@ "command": "codeQL.runVariantAnalysisContextEditor", "title": "CodeQL: Run Variant Analysis" }, + { + "command": "codeQL.runVariantAnalysisContextExplorer", + "title": "CodeQL: Run Variant Analysis" + }, + { + "command": "codeQL.runVariantAnalysisPublishedPack", + "title": "CodeQL: Run Variant Analysis against published pack" + }, { "command": "codeQL.exportSelectedVariantAnalysisResults", "title": "CodeQL: Export Variant Analysis Results" @@ -1322,6 +1329,11 @@ "group": "9_qlCommands", "when": "resourceScheme != codeql-zip-archive" }, + { + "command": "codeQL.runVariantAnalysisContextExplorer", + "group": "9_qlCommands", + "when": "resourceExtname == .ql && config.codeQL.canary && config.codeQL.variantAnalysis.multiQuery" + }, { "command": "codeQL.openReferencedFileContextExplorer", "group": "9_qlCommands", @@ -1398,6 +1410,14 @@ "command": "codeQL.runVariantAnalysis", "when": "editorLangId == ql && resourceExtname == .ql" }, + { + "command": "codeQL.runVariantAnalysisContextExplorer", + "when": "false" + }, + { + "command": "codeQL.runVariantAnalysisPublishedPack", + "when": "config.codeQL.canary && config.codeQL.variantAnalysis.multiQuery" + }, { "command": "codeQL.runVariantAnalysisContextEditor", "when": "false" @@ -1908,6 +1928,7 @@ "prepare": "cd ../.. && husky install" }, "dependencies": { + "@floating-ui/react": "^0.26.5", "@octokit/plugin-retry": "^6.0.1", "@octokit/rest": "^20.0.2", "@vscode/codicons": "^0.0.35", @@ -1921,7 +1942,7 @@ "d3-graphviz": "^5.0.2", "fs-extra": "^11.1.1", "js-yaml": "^4.1.0", - "msw": "^2.0.11", + "msw": "^2.0.13", "nanoid": "^5.0.1", "node-fetch": "^2.6.7", "p-queue": "^8.0.1", @@ -1938,8 +1959,6 @@ "vscode-extension-telemetry": "^0.1.6", "vscode-jsonrpc": "^8.0.2", "vscode-languageclient": "^8.0.2", - "vscode-test-adapter-api": "^1.7.0", - "vscode-test-adapter-util": "^0.7.0", "yauzl": "^2.10.0", "zip-a-folder": "^3.1.3" }, @@ -1953,18 +1972,18 @@ "@github/markdownlint-github": "^0.6.0", "@octokit/plugin-throttling": "^8.0.0", "@playwright/test": "^1.40.1", - "@storybook/addon-a11y": "^7.4.6", + "@storybook/addon-a11y": "^7.6.9", "@storybook/addon-actions": "^7.1.0", "@storybook/addon-essentials": "^7.1.0", "@storybook/addon-interactions": "^7.1.0", "@storybook/addon-links": "^7.1.0", "@storybook/components": "^7.6.7", "@storybook/csf": "^0.1.1", - "@storybook/manager-api": "^7.6.6", + "@storybook/manager-api": "^7.6.7", "@storybook/react": "^7.1.0", "@storybook/react-webpack5": "^7.6.7", - "@storybook/theming": "^7.6.7", - "@testing-library/dom": "^9.3.0", + "@storybook/theming": "^7.6.9", + "@testing-library/dom": "^9.3.4", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.5.2", @@ -1992,15 +2011,15 @@ "@types/unzipper": "^0.10.1", "@types/vscode": "^1.82.0", "@types/yauzl": "^2.10.3", - "@typescript-eslint/eslint-plugin": "^6.18.0", + "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.16.0", "@vscode/test-electron": "^2.2.0", "@vscode/vsce": "^2.19.0", "ansi-colors": "^4.1.1", - "applicationinsights": "^2.3.5", + "applicationinsights": "^2.9.2", "cosmiconfig": "^9.0.0", "cross-env": "^7.0.3", - "css-loader": "^6.8.1", + "css-loader": "^6.9.0", "del": "^6.0.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.0.0", @@ -2009,7 +2028,7 @@ "eslint-plugin-github": "^4.4.1", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest-dom": "^5.0.1", - "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.31.8", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^0.6.4", @@ -2029,7 +2048,7 @@ "mini-css-extract-plugin": "^2.6.1", "npm-run-all": "^4.1.5", "patch-package": "^8.0.0", - "prettier": "^3.0.0", + "prettier": "^3.2.4", "storybook": "^7.6.7", "tar-stream": "^3.0.0", "through2": "^4.0.2", diff --git a/extensions/ql-vscode/scripts/find-deadcode.ts b/extensions/ql-vscode/scripts/find-deadcode.ts index e6dea70ae..9e21286b4 100644 --- a/extensions/ql-vscode/scripts/find-deadcode.ts +++ b/extensions/ql-vscode/scripts/find-deadcode.ts @@ -9,7 +9,7 @@ function ignoreFile(file: string): boolean { containsPath(".storybook", file) || containsPath(join("src", "stories"), file) || pathsEqual( - join("test", "vscode-tests", "jest-runner-installed-extensions.ts"), + join("test", "vscode-tests", "jest-runner-vscode-codeql-cli.ts"), file, ) || basename(file) === "jest.config.ts" || diff --git a/extensions/ql-vscode/scripts/util/vscode-versions.ts b/extensions/ql-vscode/scripts/util/vscode-versions.ts index d18c247f6..ce7853620 100644 --- a/extensions/ql-vscode/scripts/util/vscode-versions.ts +++ b/extensions/ql-vscode/scripts/util/vscode-versions.ts @@ -45,8 +45,9 @@ export async function getVersionInformation( vscodeVersion: string, ): Promise { const vsCodePackageJson = await getVsCodePackageJson(vscodeVersion); - const electronVersion = minVersion(vsCodePackageJson.devDependencies.electron) - ?.version; + const electronVersion = minVersion( + vsCodePackageJson.devDependencies.electron, + )?.version; if (!electronVersion) { throw new Error("Could not find Electron version"); } diff --git a/extensions/ql-vscode/src/codeql-cli/cli-command.ts b/extensions/ql-vscode/src/codeql-cli/cli-command.ts index 1b4ab75f9..b211793e1 100644 --- a/extensions/ql-vscode/src/codeql-cli/cli-command.ts +++ b/extensions/ql-vscode/src/codeql-cli/cli-command.ts @@ -3,7 +3,10 @@ import { promisify } from "util"; import type { BaseLogger } from "../common/logging"; import type { ProgressReporter } from "../common/logging/vscode"; -import { getChildProcessErrorMessage } from "../common/helpers-pure"; +import { + getChildProcessErrorMessage, + getErrorMessage, +} from "../common/helpers-pure"; /** * Flags to pass to all cli commands. @@ -11,26 +14,27 @@ import { getChildProcessErrorMessage } from "../common/helpers-pure"; export const LOGGING_FLAGS = ["-v", "--log-to-stderr"]; /** - * Runs a CodeQL CLI command without invoking the CLI server, returning the output as a string. + * Runs a CodeQL CLI command without invoking the CLI server, deserializing the output as JSON. * @param codeQlPath The path to the CLI. * @param command The `codeql` command to be run, provided as an array of command/subcommand names. * @param commandArgs The arguments to pass to the `codeql` command. * @param description Description of the action being run, to be shown in log and error messages. * @param logger Logger to write command log messages, e.g. to an output channel. * @param progressReporter Used to output progress messages, e.g. to the status bar. - * @returns The contents of the command's stdout, if the command succeeded. + * @returns A JSON object parsed from the contents of the command's stdout, if the command succeeded. */ -export async function runCodeQlCliCommand( +export async function runJsonCodeQlCliCommand( codeQlPath: string, command: string[], commandArgs: string[], description: string, logger: BaseLogger, progressReporter?: ProgressReporter, -): Promise { +): Promise { // Add logging arguments first, in case commandArgs contains positional parameters. const args = command.concat(LOGGING_FLAGS).concat(commandArgs); const argsString = args.join(" "); + let stdout: string; try { if (progressReporter !== undefined) { progressReporter.report({ message: description }); @@ -41,10 +45,18 @@ export async function runCodeQlCliCommand( const result = await promisify(execFile)(codeQlPath, args); void logger.log(result.stderr); void logger.log("CLI command succeeded."); - return result.stdout; + stdout = result.stdout; } catch (err) { throw new Error( `${description} failed: ${getChildProcessErrorMessage(err)}`, ); } + + try { + return JSON.parse(stdout) as OutputType; + } catch (err) { + throw new Error( + `Parsing output of ${description} failed: ${getErrorMessage(err)}`, + ); + } } diff --git a/extensions/ql-vscode/src/codeql-cli/cli-version.ts b/extensions/ql-vscode/src/codeql-cli/cli-version.ts index 17e995d02..975ed7d79 100644 --- a/extensions/ql-vscode/src/codeql-cli/cli-version.ts +++ b/extensions/ql-vscode/src/codeql-cli/cli-version.ts @@ -1,25 +1,49 @@ import type { SemVer } from "semver"; import { parse } from "semver"; -import { runCodeQlCliCommand } from "./cli-command"; +import { runJsonCodeQlCliCommand } from "./cli-command"; import type { Logger } from "../common/logging"; import { getErrorMessage } from "../common/helpers-pure"; +interface VersionResult { + version: string; + features: CliFeatures | undefined; +} + +export interface CliFeatures { + featuresInVersionResult?: boolean; + mrvaPackCreate?: boolean; +} + +export interface VersionAndFeatures { + version: SemVer; + features: CliFeatures; +} + /** * Get the version of a CodeQL CLI. */ export async function getCodeQlCliVersion( codeQlPath: string, logger: Logger, -): Promise { +): Promise { try { - const output: string = await runCodeQlCliCommand( + const output: VersionResult = await runJsonCodeQlCliCommand( codeQlPath, ["version"], - ["--format=terse"], + ["--format=json"], "Checking CodeQL version", logger, ); - return parse(output.trim()) || undefined; + + const version = parse(output.version.trim()) || undefined; + if (version === undefined) { + return undefined; + } + + return { + version, + features: output.features ?? {}, + }; } catch (e) { // Failed to run the version command. This might happen if the cli version is _really_ old, or it is corrupted. // Either way, we can't determine compatibility. diff --git a/extensions/ql-vscode/src/codeql-cli/cli.ts b/extensions/ql-vscode/src/codeql-cli/cli.ts index 86678349a..8ff704a36 100644 --- a/extensions/ql-vscode/src/codeql-cli/cli.ts +++ b/extensions/ql-vscode/src/codeql-cli/cli.ts @@ -34,6 +34,7 @@ import { QueryLanguage } from "../common/query-language"; import { LINE_ENDINGS, splitStreamAtSeparators } from "../common/split-stream"; import type { Position } from "../query-server/messages"; import { LOGGING_FLAGS } from "./cli-command"; +import type { CliFeatures, VersionAndFeatures } from "./cli-version"; /** * The version of the SARIF format that we are using. @@ -121,6 +122,16 @@ type GenerateExtensiblePredicateMetadataResult = { }>; }; +type PackDownloadResult = { + // There are other properties in this object, but they are + // not relevant for its use in the extension, so we omit them. + packs: Array<{ + name: string; + version: string; + }>; + packDir: string; +}; + /** * The expected output of `codeql resolve qlref`. */ @@ -193,7 +204,9 @@ type OnLineCallback = ( line: string, ) => Promise | string | undefined; -type VersionChangedListener = (newVersion: SemVer | undefined) => void; +type VersionChangedListener = ( + newVersionAndFeatures: VersionAndFeatures | undefined, +) => void; /** * This class manages a cli server started by `codeql execute cli-server` to @@ -211,8 +224,8 @@ export class CodeQLCliServer implements Disposable { /** A buffer with a single null byte. */ nullBuffer: Buffer; - /** Version of current cli, lazily computed by the `getVersion()` method */ - private _version: SemVer | undefined; + /** Version of current cli and its supported features, lazily computed by the `getVersion()` method */ + private _versionAndFeatures: VersionAndFeatures | undefined; private _versionChangedListeners: VersionChangedListener[] = []; @@ -288,7 +301,7 @@ export class CodeQLCliServer implements Disposable { const callback = (): void => { try { this.killProcessIfRunning(); - this._version = undefined; + this._versionAndFeatures = undefined; this._supportedLanguages = undefined; } finally { this.runNext(); @@ -1383,7 +1396,7 @@ export class CodeQLCliServer implements Disposable { * Downloads a specified pack. * @param packs The `` of the packs to download. */ - async packDownload(packs: string[]) { + async packDownload(packs: string[]): Promise { return this.runJsonCodeQlCliCommandWithAuthentication( ["pack", "download"], packs, @@ -1417,16 +1430,28 @@ export class CodeQLCliServer implements Disposable { ); } + /** + * Compile a CodeQL pack and bundle it into a single file. + * + * @param sourcePackDir The directory of the input CodeQL pack. + * @param workspaceFolders The workspace folders to search for additional packs. + * @param outputBundleFile The path to the output bundle file. + * @param outputPackDir The directory to contain the unbundled output pack. + * @param moreOptions Additional options to be passed to `codeql pack bundle`. + */ async packBundle( - dir: string, + sourcePackDir: string, workspaceFolders: string[], - outputPath: string, + outputBundleFile: string, + outputPackDir: string, moreOptions: string[], ): Promise { const args = [ "-o", - outputPath, - dir, + outputBundleFile, + sourcePackDir, + "--pack-path", + outputPackDir, ...moreOptions, ...this.getAdditionalPacksArg(workspaceFolders), ]; @@ -1481,27 +1506,35 @@ export class CodeQLCliServer implements Disposable { ); } - public async getVersion() { - if (!this._version) { + public async getVersion(): Promise { + return (await this.getVersionAndFeatures()).version; + } + + public async getFeatures(): Promise { + return (await this.getVersionAndFeatures()).features; + } + + private async getVersionAndFeatures(): Promise { + if (!this._versionAndFeatures) { try { - const newVersion = await this.refreshVersion(); - this._version = newVersion; + const newVersionAndFeatures = await this.refreshVersion(); + this._versionAndFeatures = newVersionAndFeatures; this._versionChangedListeners.forEach((listener) => - listener(newVersion), + listener(newVersionAndFeatures), ); // this._version is only undefined upon config change, so we reset CLI-based context key only when necessary. await this.app.commands.execute( "setContext", "codeql.supportsQuickEvalCount", - newVersion.compare( + newVersionAndFeatures.version.compare( CliVersionConstraint.CLI_VERSION_WITH_QUICK_EVAL_COUNT, ) >= 0, ); await this.app.commands.execute( "setContext", "codeql.supportsTrimCache", - newVersion.compare( + newVersionAndFeatures.version.compare( CliVersionConstraint.CLI_VERSION_WITH_TRIM_CACHE, ) >= 0, ); @@ -1512,23 +1545,23 @@ export class CodeQLCliServer implements Disposable { throw e; } } - return this._version; + return this._versionAndFeatures; } public addVersionChangedListener(listener: VersionChangedListener) { - if (this._version) { - listener(this._version); + if (this._versionAndFeatures) { + listener(this._versionAndFeatures); } this._versionChangedListeners.push(listener); } - private async refreshVersion() { + private async refreshVersion(): Promise { const distribution = await this.distributionProvider.getDistribution(); switch (distribution.kind) { case FindDistributionResultKind.CompatibleDistribution: - + // eslint-disable-next-line no-fallthrough -- Intentional fallthrough case FindDistributionResultKind.IncompatibleDistribution: - return distribution.version; + return distribution.versionAndFeatures; default: // We should not get here because if no distributions are available, then @@ -1647,7 +1680,7 @@ function isEnvTrue(name: string): boolean { ); } -export function shouldDebugIdeServer() { +export function shouldDebugLanguageServer() { return isEnvTrue("IDE_SERVER_JAVA_DEBUG"); } @@ -1745,4 +1778,8 @@ export class CliVersionConstraint { CliVersionConstraint.CLI_VERSION_WITH_EXTENSIBLE_PREDICATE_METADATA, ); } + + async supportsMrvaPackCreate(): Promise { + return (await this.cli.getFeatures()).mrvaPackCreate === true; + } } diff --git a/extensions/ql-vscode/src/codeql-cli/distribution.ts b/extensions/ql-vscode/src/codeql-cli/distribution.ts index 108fb4e01..e69fca721 100644 --- a/extensions/ql-vscode/src/codeql-cli/distribution.ts +++ b/extensions/ql-vscode/src/codeql-cli/distribution.ts @@ -1,11 +1,11 @@ import { createWriteStream, mkdtemp, pathExists, remove } from "fs-extra"; import { tmpdir } from "os"; import { delimiter, dirname, join } from "path"; -import type { SemVer } from "semver"; import { Range, satisfies } from "semver"; import type { Event, ExtensionContext } from "vscode"; import type { DistributionConfig } from "../config"; import { extLogger } from "../common/logging/vscode"; +import type { VersionAndFeatures } from "./cli-version"; import { getCodeQlCliVersion } from "./cli-version"; import type { ProgressCallback } from "../common/vscode/progress"; import { reportStreamProgress } from "../common/vscode/progress"; @@ -88,11 +88,11 @@ export class DistributionManager implements DistributionProvider { kind: FindDistributionResultKind.NoDistribution, }; } - const version = await getCodeQlCliVersion( + const versionAndFeatures = await getCodeQlCliVersion( distribution.codeQlPath, extLogger, ); - if (version === undefined) { + if (versionAndFeatures === undefined) { return { distribution, kind: FindDistributionResultKind.UnknownCompatibilityDistribution, @@ -119,17 +119,21 @@ export class DistributionManager implements DistributionProvider { distribution.kind !== DistributionKind.ExtensionManaged || this.config.includePrerelease; - if (!satisfies(version, this.versionRange, { includePrerelease })) { + if ( + !satisfies(versionAndFeatures.version, this.versionRange, { + includePrerelease, + }) + ) { return { distribution, kind: FindDistributionResultKind.IncompatibleDistribution, - version, + versionAndFeatures, }; } return { distribution, kind: FindDistributionResultKind.CompatibleDistribution, - version, + versionAndFeatures, }; } @@ -599,7 +603,7 @@ interface DistributionResult { interface CompatibleDistributionResult extends DistributionResult { kind: FindDistributionResultKind.CompatibleDistribution; - version: SemVer; + versionAndFeatures: VersionAndFeatures; } interface UnknownCompatibilityDistributionResult extends DistributionResult { @@ -608,7 +612,7 @@ interface UnknownCompatibilityDistributionResult extends DistributionResult { interface IncompatibleDistributionResult extends DistributionResult { kind: FindDistributionResultKind.IncompatibleDistribution; - version: SemVer; + versionAndFeatures: VersionAndFeatures; } interface NoDistributionResult { diff --git a/extensions/ql-vscode/src/codeql-cli/query-language.ts b/extensions/ql-vscode/src/codeql-cli/query-language.ts index 53d0528f1..c711f1568 100644 --- a/extensions/ql-vscode/src/codeql-cli/query-language.ts +++ b/extensions/ql-vscode/src/codeql-cli/query-language.ts @@ -63,7 +63,7 @@ export async function askForLanguage( .sort((a, b) => a.label.localeCompare(b.label)); const selectedItem = await window.showQuickPick(items, { - placeHolder: "Select target language for your query", + placeHolder: "Select target query language", ignoreFocusOut: true, }); if (!selectedItem) { diff --git a/extensions/ql-vscode/src/common/commands.ts b/extensions/ql-vscode/src/common/commands.ts index dbd640b3b..b1463a848 100644 --- a/extensions/ql-vscode/src/common/commands.ts +++ b/extensions/ql-vscode/src/common/commands.ts @@ -1,10 +1,9 @@ import type { CommandManager } from "../packages/commands"; -import type { Uri, Range, TextDocumentShowOptions } from "vscode"; +import type { Uri, Range, TextDocumentShowOptions, TestItem } from "vscode"; import type { AstItem } from "../language-support"; import type { DbTreeViewItem } from "../databases/ui/db-tree-view-item"; import type { DatabaseItem } from "../databases/local-databases"; import type { QueryHistoryInfo } from "../query-history/query-history-info"; -import type { TestTreeNode } from "../query-testing/test-tree-node"; import type { VariantAnalysis, VariantAnalysisScannedRepository, @@ -276,9 +275,11 @@ export type VariantAnalysisCommands = { "codeQL.openVariantAnalysisView": ( variantAnalysisId: number, ) => Promise; - "codeQL.runVariantAnalysis": (uri?: Uri) => Promise; - "codeQL.runVariantAnalysisContextEditor": (uri?: Uri) => Promise; + "codeQL.runVariantAnalysis": () => Promise; + "codeQL.runVariantAnalysisContextEditor": (uri: Uri) => Promise; + "codeQL.runVariantAnalysisContextExplorer": ExplorerSelectionCommandFunction; "codeQLQueries.runVariantAnalysisContextMenu": TreeViewContextSingleSelectionCommandFunction; + "codeQL.runVariantAnalysisPublishedPack": () => Promise; }; export type DatabasePanelCommands = { @@ -334,11 +335,9 @@ export type SummaryLanguageSupportCommands = { }; export type TestUICommands = { - "codeQLTests.showOutputDifferences": (node: TestTreeNode) => Promise; - "codeQLTests.acceptOutput": (node: TestTreeNode) => Promise; - "codeQLTests.acceptOutputContextTestItem": ( - node: TestTreeNode, - ) => Promise; + "codeQLTests.showOutputDifferences": (node: TestItem) => Promise; + "codeQLTests.acceptOutput": (node: TestItem) => Promise; + "codeQLTests.acceptOutputContextTestItem": (node: TestItem) => Promise; }; export type MockGitHubApiServerCommands = { diff --git a/extensions/ql-vscode/src/common/helpers-pure.ts b/extensions/ql-vscode/src/common/helpers-pure.ts index 319faef36..40f659e9d 100644 --- a/extensions/ql-vscode/src/common/helpers-pure.ts +++ b/extensions/ql-vscode/src/common/helpers-pure.ts @@ -27,26 +27,26 @@ export function assertNever(value: never): never { /** * Use to perform array filters where the predicate is asynchronous. */ -export const asyncFilter = async function ( +export async function asyncFilter( arr: T[], predicate: (arg0: T) => Promise, ) { const results = await Promise.all(arr.map(predicate)); return arr.filter((_, index) => results[index]); -}; +} /** * This regex matches strings of the form `owner/repo` where: * - `owner` is made up of alphanumeric characters, hyphens, underscores, or periods * - `repo` is made up of alphanumeric characters, hyphens, underscores, or periods */ -export const REPO_REGEX = /^[a-zA-Z0-9-_\.]+\/[a-zA-Z0-9-_\.]+$/; +export const REPO_REGEX = /^[a-zA-Z0-9-_.]+\/[a-zA-Z0-9-_.]+$/; /** * This regex matches GiHub organization and user strings. These are made up for alphanumeric * characters, hyphens, underscores or periods. */ -export const OWNER_REGEX = /^[a-zA-Z0-9-_\.]+$/; +export const OWNER_REGEX = /^[a-zA-Z0-9-_.]+$/; export function getErrorMessage(e: unknown): string { if (e instanceof RedactableError) { diff --git a/extensions/ql-vscode/src/common/logging/vscode/loggers.ts b/extensions/ql-vscode/src/common/logging/vscode/loggers.ts index ff91b00fb..8d4401dfe 100644 --- a/extensions/ql-vscode/src/common/logging/vscode/loggers.ts +++ b/extensions/ql-vscode/src/common/logging/vscode/loggers.ts @@ -11,7 +11,7 @@ export const extLogger = new OutputChannelLogger("CodeQL Extension Log"); export const queryServerLogger = new OutputChannelLogger("CodeQL Query Server"); // Logger for messages from the language server. -export const ideServerLogger = new OutputChannelLogger( +export const languageServerLogger = new OutputChannelLogger( "CodeQL Language Server", ); diff --git a/extensions/ql-vscode/src/common/readonly.ts b/extensions/ql-vscode/src/common/readonly.ts index d6bf75098..19a4ad963 100644 --- a/extensions/ql-vscode/src/common/readonly.ts +++ b/extensions/ql-vscode/src/common/readonly.ts @@ -1,11 +1,12 @@ -export type DeepReadonly = T extends Array - ? DeepReadonlyArray - : // eslint-disable-next-line @typescript-eslint/ban-types - T extends Function - ? T - : T extends object - ? DeepReadonlyObject - : T; +export type DeepReadonly = + T extends Array + ? DeepReadonlyArray + : // eslint-disable-next-line @typescript-eslint/ban-types + T extends Function + ? T + : T extends object + ? DeepReadonlyObject + : T; interface DeepReadonlyArray extends ReadonlyArray> {} diff --git a/extensions/ql-vscode/src/common/sarif-utils.ts b/extensions/ql-vscode/src/common/sarif-utils.ts index d69df80ba..61e3d3a38 100644 --- a/extensions/ql-vscode/src/common/sarif-utils.ts +++ b/extensions/ql-vscode/src/common/sarif-utils.ts @@ -47,7 +47,7 @@ export function parseSarifPlainTextMessage( // Technically we could have any uri in the target but we don't output that yet. // The possibility of escaping outside the link is not mentioned in the sarif spec but we always output sartif this way. const linkRegex = - /(?<=(?([^\\\]\[]|\\\\|\\\]|\\\[)*)\]\((?[0-9]+)\)/g; + /(?<=(?([^\\\][]|\\\\|\\\]|\\\[)*)\]\((?[0-9]+)\)/g; let result: RegExpExecArray | null; let curIndex = 0; while ((result = linkRegex.exec(message)) !== null) { diff --git a/extensions/ql-vscode/src/common/short-paths.ts b/extensions/ql-vscode/src/common/short-paths.ts new file mode 100644 index 000000000..838dac031 --- /dev/null +++ b/extensions/ql-vscode/src/common/short-paths.ts @@ -0,0 +1,117 @@ +import { platform } from "os"; +import { basename, dirname, join, normalize, resolve } from "path"; +import { lstat, readdir } from "fs/promises"; +import type { BaseLogger } from "./logging"; + +/** + * Expands a path that potentially contains 8.3 short names (e.g. "C:\PROGRA~1" instead of "C:\Program Files"). + * + * See https://en.wikipedia.org/wiki/8.3_filename if you're not familiar with Windows 8.3 short names. + * + * @param shortPath The path to expand. + * @returns A normalized, absolute path, with any short components expanded. + */ +export async function expandShortPaths( + shortPath: string, + logger: BaseLogger, +): Promise { + const absoluteShortPath = normalize(resolve(shortPath)); + if (platform() !== "win32") { + // POSIX doesn't have short paths. + return absoluteShortPath; + } + + void logger.log(`Expanding short paths in: ${absoluteShortPath}`); + // A quick check to see if there might be any short components. + // There might be a case where a short component doesn't contain a `~`, but if there is, I haven't + // found it. + // This may find long components that happen to have a '~', but that's OK. + if (absoluteShortPath.indexOf("~") < 0) { + // No short components to expand. + void logger.log(`Skipping due to no short components`); + return absoluteShortPath; + } + + return await expandShortPathRecursive(absoluteShortPath, logger); +} + +/** + * Expand a single short path component + * @param dir The absolute path of the directory containing the short path component. + * @param shortBase The shot path component to expand. + * @returns The expanded path component. + */ +async function expandShortPathComponent( + dir: string, + shortBase: string, + logger: BaseLogger, +): Promise { + void logger.log(`Expanding short path component: ${shortBase}`); + + const fullPath = join(dir, shortBase); + + // Use `lstat` instead of `stat` to avoid following symlinks. + const stats = await lstat(fullPath, { bigint: true }); + if (stats.dev === BigInt(0) || stats.ino === BigInt(0)) { + // No inode info, so we won't be able to find this in the directory listing. + void logger.log(`No inode info available. Skipping.`); + return shortBase; + } + void logger.log(`dev/inode: ${stats.dev}/${stats.ino}`); + + try { + // Enumerate the children of the parent directory, and try to find one with the same dev/inode. + const children = await readdir(dir); + for (const child of children) { + void logger.log(`considering child: ${child}`); + try { + const childStats = await lstat(join(dir, child), { bigint: true }); + void logger.log(`child dev/inode: ${childStats.dev}/${childStats.ino}`); + if (childStats.dev === stats.dev && childStats.ino === stats.ino) { + // Found a match. + void logger.log(`Found a match: ${child}`); + return child; + } + } catch (e) { + // Can't read stats for the child, so skip it. + void logger.log(`Error reading stats for child: ${e}`); + } + } + } catch (e) { + // Can't read the directory, so we won't be able to find this in the directory listing. + void logger.log(`Error reading directory: ${e}`); + return shortBase; + } + + void logger.log(`No match found. Returning original.`); + return shortBase; +} + +/** + * Expand the short path components in a path, including those in ancestor directories. + * @param shortPath The path to expand. + * @returns The expanded path. + */ +async function expandShortPathRecursive( + shortPath: string, + logger: BaseLogger, +): Promise { + const shortBase = basename(shortPath); + if (shortBase.length === 0) { + // We've reached the root. + return shortPath; + } + + const dir = await expandShortPathRecursive(dirname(shortPath), logger); + void logger.log(`dir: ${dir}`); + void logger.log(`base: ${shortBase}`); + if (shortBase.indexOf("~") < 0) { + // This component doesn't have a short name, so just append it to the (long) parent. + void logger.log(`Component is not a short name`); + return join(dir, shortBase); + } + + // This component looks like it has a short name, so try to expand it. + const longBase = await expandShortPathComponent(dir, shortBase, logger); + return join(dir, longBase); +} diff --git a/extensions/ql-vscode/src/common/vscode/telemetry.ts b/extensions/ql-vscode/src/common/vscode/telemetry.ts index 1068ab9ee..ebad18a88 100644 --- a/extensions/ql-vscode/src/common/vscode/telemetry.ts +++ b/extensions/ql-vscode/src/common/vscode/telemetry.ts @@ -212,6 +212,22 @@ export class ExtensionTelemetryListener this.reporter.sendTelemetryErrorEvent("error", properties, {}); } + sendConfigInformation(config: Record): void { + if (!this.reporter) { + return; + } + + this.reporter.sendTelemetryEvent( + "config", + { + ...config, + isCanary: isCanary().toString(), + cliVersion: this.cliVersionStr, + }, + {}, + ); + } + /** * Displays a popup asking the user if they want to enable telemetry * for this extension. diff --git a/extensions/ql-vscode/src/config.ts b/extensions/ql-vscode/src/config.ts index 7145c6b23..095b53d96 100644 --- a/extensions/ql-vscode/src/config.ts +++ b/extensions/ql-vscode/src/config.ts @@ -653,7 +653,7 @@ export function allowHttp(): boolean { ); } -const ADD_DATABASE_SOURCE_TO_WORKSPACE_SETTING = new Setting( +export const ADD_DATABASE_SOURCE_TO_WORKSPACE_SETTING = new Setting( "addDatabaseSourceToWorkspace", ADDING_DATABASES_SETTING, ); diff --git a/extensions/ql-vscode/src/databases/local-databases-ui.ts b/extensions/ql-vscode/src/databases/local-databases-ui.ts index 608626bd8..674c1ecd3 100644 --- a/extensions/ql-vscode/src/databases/local-databases-ui.ts +++ b/extensions/ql-vscode/src/databases/local-databases-ui.ts @@ -5,6 +5,7 @@ import type { ProviderResult, TreeDataProvider, CancellationToken, + QuickPickItem, } from "vscode"; import { EventEmitter, @@ -28,7 +29,11 @@ import type { ProgressCallback, ProgressContext, } from "../common/vscode/progress"; -import { withInheritedProgress, withProgress } from "../common/vscode/progress"; +import { + UserCancellationException, + withInheritedProgress, + withProgress, +} from "../common/vscode/progress"; import { isLikelyDatabaseRoot, isLikelyDbLanguageFolder, @@ -52,7 +57,10 @@ import { createMultiSelectionCommand, createSingleSelectionCommand, } from "../common/vscode/selection-commands"; -import { tryGetQueryLanguage } from "../common/query-language"; +import { + getLanguageDisplayName, + tryGetQueryLanguage, +} from "../common/query-language"; import type { LanguageContextStore } from "../language-context-store"; enum SortOrder { @@ -227,6 +235,18 @@ async function chooseDatabaseDir(byFolder: boolean): Promise { return getFirst(chosen); } +interface DatabaseSelectionQuickPickItem extends QuickPickItem { + databaseKind: "new" | "existing"; +} + +export interface DatabaseQuickPickItem extends QuickPickItem { + databaseItem: DatabaseItem; +} + +interface DatabaseImportQuickPickItems extends QuickPickItem { + importType: "URL" | "github" | "archive" | "folder"; +} + export class DatabaseUI extends DisposableObject { private treeDataProvider: DatabaseTreeDataProvider; @@ -794,13 +814,120 @@ export class DatabaseUI extends DisposableObject { * notification if it tries to perform any long-running operations. */ private async getDatabaseItemInternal( - progress: ProgressContext | undefined, + progressContext: ProgressContext | undefined, ): Promise { if (this.databaseManager.currentDatabaseItem === undefined) { - await this.chooseAndSetDatabase(false, progress); + progressContext?.progress({ + maxStep: 2, + step: 1, + message: "Choosing database", + }); + await this.promptForDatabase(); + } + return this.databaseManager.currentDatabaseItem; + } + + private async promptForDatabase(): Promise { + const quickPickItems: DatabaseSelectionQuickPickItem[] = [ + { + label: "$(database) Existing database", + detail: "Select an existing database from your workspace", + alwaysShow: true, + databaseKind: "existing", + }, + { + label: "$(arrow-down) New database", + detail: "Import a new database from the cloud or your local machine", + alwaysShow: true, + databaseKind: "new", + }, + ]; + const selectedOption = + await window.showQuickPick( + quickPickItems, + { + placeHolder: "Select an option", + ignoreFocusOut: true, + }, + ); + + if (!selectedOption) { + throw new UserCancellationException("No database selected", true); } - return this.databaseManager.currentDatabaseItem; + if (selectedOption.databaseKind === "existing") { + await this.selectExistingDatabase(); + } else if (selectedOption.databaseKind === "new") { + await this.importNewDatabase(); + } + } + + private async selectExistingDatabase() { + const dbItems: DatabaseQuickPickItem[] = + this.databaseManager.databaseItems.map((dbItem) => ({ + label: dbItem.name, + description: getLanguageDisplayName(dbItem.language), + databaseItem: dbItem, + })); + + const selectedDatabase = await window.showQuickPick(dbItems, { + placeHolder: "Select a database", + ignoreFocusOut: true, + }); + + if (!selectedDatabase) { + throw new UserCancellationException("No database selected", true); + } + + await this.databaseManager.setCurrentDatabaseItem( + selectedDatabase.databaseItem, + ); + } + + private async importNewDatabase() { + const importOptions: DatabaseImportQuickPickItems[] = [ + { + label: "$(github) GitHub", + detail: "Import a database from a GitHub repository", + alwaysShow: true, + importType: "github", + }, + { + label: "$(link) URL", + detail: "Import a database archive or folder from a remote URL", + alwaysShow: true, + importType: "URL", + }, + { + label: "$(file-zip) Archive", + detail: "Import a database from a local ZIP archive", + alwaysShow: true, + importType: "archive", + }, + { + label: "$(folder) Folder", + detail: "Import a database from a local folder", + alwaysShow: true, + importType: "folder", + }, + ]; + const selectedImportOption = + await window.showQuickPick(importOptions, { + placeHolder: "Import a database from...", + ignoreFocusOut: true, + }); + if (!selectedImportOption) { + throw new UserCancellationException("No database selected", true); + } + if (selectedImportOption.importType === "github") { + await this.handleChooseDatabaseGithub(); + } else if (selectedImportOption.importType === "URL") { + await this.handleChooseDatabaseInternet(); + } else if (selectedImportOption.importType === "archive") { + await this.handleChooseDatabaseArchive(); + } else if (selectedImportOption.importType === "folder") { + await this.handleChooseDatabaseFolder(); + } } /** diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index e646f6fa6..8e4680139 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -15,8 +15,6 @@ import { arch, homedir, platform } from "os"; import { ensureDir } from "fs-extra"; import { join } from "path"; import { dirSync } from "tmp-promise"; -import type { TestHub } from "vscode-test-adapter-api"; -import { testExplorerExtensionId } from "vscode-test-adapter-api"; import { lt, parse } from "semver"; import { watch } from "chokidar"; import { @@ -25,17 +23,18 @@ import { } from "./common/vscode/archive-filesystem-provider"; import { CliVersionConstraint, CodeQLCliServer } from "./codeql-cli/cli"; import { + ADD_DATABASE_SOURCE_TO_WORKSPACE_SETTING, + addDatabaseSourceToWorkspace, CliConfigListener, DistributionConfigListener, GitHubDatabaseConfigListener, - isCanary, joinOrderWarningThreshold, QueryHistoryConfigListener, QueryServerConfigListener, } from "./config"; import { AstViewer, - createIDEServer, + createLanguageClient, getQueryEditorCommands, install, TemplatePrintAstProvider, @@ -85,13 +84,11 @@ import { import type { ProgressReporter } from "./common/logging/vscode"; import { extLogger, - ideServerLogger, + languageServerLogger, queryServerLogger, } from "./common/logging/vscode"; import { QueryHistoryManager } from "./query-history/query-history-manager"; import type { CompletedLocalQueryInfo } from "./query-results"; -import { QLTestAdapterFactory } from "./query-testing/test-adapter"; -import { TestUIService } from "./query-testing/test-ui"; import { CompareView } from "./compare/compare-view"; import { initializeTelemetry, @@ -130,7 +127,6 @@ import { DebuggerUI } from "./debugger/debugger-ui"; import { ModelEditorModule } from "./model-editor/model-editor-module"; import { TestManager } from "./query-testing/test-manager"; import { TestRunner } from "./query-testing/test-runner"; -import type { TestManagerBase } from "./query-testing/test-manager-base"; import { QueryRunner, QueryServerClient } from "./query-server"; import { QueriesModule } from "./queries-panel/queries-module"; import { OpenReferencedFileCodeLensProvider } from "./local-queries/open-referenced-file-code-lens-provider"; @@ -175,7 +171,7 @@ function getCommands( app: App, cliServer: CodeQLCliServer, queryRunner: QueryRunner, - ideServer: LanguageClient, + languageClient: LanguageClient, ): BaseCommands { const getCliVersion = async () => { try { @@ -193,10 +189,10 @@ function getCommands( await Promise.all([ queryRunner.restartQueryServer(progress, token), async () => { - if (ideServer.isRunning()) { - await ideServer.restart(); + if (languageClient.isRunning()) { + await languageClient.restart(); } else { - await ideServer.start(); + await languageClient.start(); } }, ]); @@ -219,8 +215,9 @@ function getCommands( "codeQL.restartLegacyQueryServerOnConfigChange": restartQueryServer, "codeQL.restartQueryServerOnExternalConfigChange": restartQueryServer, "codeQL.copyVersion": async () => { - const text = `CodeQL extension version: ${extension?.packageJSON - .version} \nCodeQL CLI version: ${await getCliVersion()} \nPlatform: ${platform()} ${arch()}`; + const text = `CodeQL extension version: ${ + extension?.packageJSON.version + } \nCodeQL CLI version: ${await getCliVersion()} \nPlatform: ${platform()} ${arch()}`; await env.clipboard.writeText(text); void showAndLogInformationMessage(extLogger, text); }, @@ -308,6 +305,14 @@ const codeQlVersionRange = DEFAULT_DISTRIBUTION_VERSION_RANGE; // before silently being refused to upgrade. const MIN_VERSION = "1.82.0"; +function sendConfigTelemetryData() { + const config: Record = {}; + config[ADD_DATABASE_SOURCE_TO_WORKSPACE_SETTING.qualifiedName] = + addDatabaseSourceToWorkspace().toString(); + + telemetryListener?.sendConfigInformation(config); +} + /** * Returns the CodeQLExtensionInterface, or an empty object if the interface is not * available after activation is complete. This will happen if there is no cli @@ -335,6 +340,8 @@ export async function activate( const app = new ExtensionApp(ctx); + sendConfigTelemetryData(); + const quickEvalCodeLensProvider = new QuickEvalCodeLensProvider(); languages.registerCodeLensProvider( { scheme: "file", language: "ql" }, @@ -424,7 +431,7 @@ export async function activate( codeQlExtension.variantAnalysisManager, ); codeQlExtension.cliServer.addVersionChangedListener((ver) => { - telemetryListener.cliVersion = ver; + telemetryListener.cliVersion = ver?.version; }); let unsupportedWarningShown = false; @@ -437,13 +444,16 @@ export async function activate( return; } - if (CliVersionConstraint.OLDEST_SUPPORTED_CLI_VERSION.compare(ver) < 0) { + if ( + CliVersionConstraint.OLDEST_SUPPORTED_CLI_VERSION.compare(ver.version) < + 0 + ) { return; } void showAndLogWarningMessage( extLogger, - `You are using an unsupported version of the CodeQL CLI (${ver}). ` + + `You are using an unsupported version of the CodeQL CLI (${ver.version}). ` + `The minimum supported version is ${CliVersionConstraint.OLDEST_SUPPORTED_CLI_VERSION}. ` + `Please upgrade to a newer version of the CodeQL CLI.`, ); @@ -598,7 +608,7 @@ async function getDistributionDisplayingDistributionWarnings( switch (result.kind) { case FindDistributionResultKind.CompatibleDistribution: void extLogger.log( - `Found compatible version of CodeQL CLI (version ${result.version.raw})`, + `Found compatible version of CodeQL CLI (version ${result.versionAndFeatures.version.raw})`, ); break; case FindDistributionResultKind.IncompatibleDistribution: { @@ -607,18 +617,18 @@ async function getDistributionDisplayingDistributionWarnings( case DistributionKind.ExtensionManaged: return 'Please update the CodeQL CLI by running the "CodeQL: Check for CLI Updates" command.'; case DistributionKind.CustomPathConfig: - return `Please update the \"CodeQL CLI Executable Path\" setting to point to a CLI in the version range ${codeQlVersionRange}.`; + return `Please update the "CodeQL CLI Executable Path" setting to point to a CLI in the version range ${codeQlVersionRange}.`; case DistributionKind.PathEnvironmentVariable: return ( `Please update the CodeQL CLI on your PATH to a version compatible with ${codeQlVersionRange}, or ` + - `set the \"CodeQL CLI Executable Path\" setting to the path of a CLI version compatible with ${codeQlVersionRange}.` + `set the "CodeQL CLI Executable Path" setting to the path of a CLI version compatible with ${codeQlVersionRange}.` ); } })(); void showAndLogWarningMessage( extLogger, - `The current version of the CodeQL CLI (${result.version.raw}) ` + + `The current version of the CodeQL CLI (${result.versionAndFeatures.version.raw}) ` + `is incompatible with this extension. ${fixGuidanceMessage}`, ); break; @@ -936,7 +946,7 @@ async function activateWithInstalledDistribution( ctx.subscriptions.push(tmpDirDisposal); void extLogger.log("Initializing CodeQL language server."); - const ideServer = createIDEServer(qlConfigurationListener); + const languageClient = createLanguageClient(qlConfigurationListener); const localQueries = new LocalQueries( app, @@ -977,27 +987,8 @@ async function activateWithInstalledDistribution( const testRunner = new TestRunner(dbm, cliServer); ctx.subscriptions.push(testRunner); - let testManager: TestManagerBase | undefined = undefined; - if (isCanary()) { - testManager = new TestManager(app, testRunner, cliServer); - ctx.subscriptions.push(testManager); - } else { - const testExplorerExtension = extensions.getExtension( - testExplorerExtensionId, - ); - if (testExplorerExtension) { - const testHub = testExplorerExtension.exports; - const testAdapterFactory = new QLTestAdapterFactory( - testHub, - testRunner, - cliServer, - ); - ctx.subscriptions.push(testAdapterFactory); - - testManager = new TestUIService(app, testHub); - ctx.subscriptions.push(testManager); - } - } + const testManager = new TestManager(app, testRunner, cliServer); + ctx.subscriptions.push(testManager); const testUiCommands = testManager?.getCommands() ?? {}; @@ -1021,7 +1012,7 @@ async function activateWithInstalledDistribution( void extLogger.log("Registering top-level command palette commands."); const allCommands: AllExtensionCommands = { - ...getCommands(app, cliServer, qs, ideServer), + ...getCommands(app, cliServer, qs, languageClient), ...getQueryEditorCommands({ commandManager: app.commands, queryRunner: qs, @@ -1068,21 +1059,21 @@ async function activateWithInstalledDistribution( } void extLogger.log("Starting language server."); - await ideServer.start(); + await languageClient.start(); ctx.subscriptions.push({ dispose: () => { - void ideServer.stop(); + void languageClient.stop(); }, }); - // Handle visibility changes in the ideserver + // Handle visibility changes in the CodeQL language client. if (await cliServer.cliConstraints.supportsVisibilityNotifications()) { Window.onDidChangeVisibleTextEditors((editors) => { - ideServer.notifyVisibilityChange(editors); + languageClient.notifyVisibilityChange(editors); }); // Send an inital notification to the language server // to set the initial state of the visible editors. - ideServer.notifyVisibilityChange(Window.visibleTextEditors); + languageClient.notifyVisibilityChange(Window.visibleTextEditors); } // Jump-to-definition and find-references @@ -1264,7 +1255,7 @@ function getContextStoragePath(ctx: ExtensionContext) { async function initializeLogging(ctx: ExtensionContext): Promise { ctx.subscriptions.push(extLogger); ctx.subscriptions.push(queryServerLogger); - ctx.subscriptions.push(ideServerLogger); + ctx.subscriptions.push(languageServerLogger); } const checkForUpdatesCommand: keyof PreActivationCommands = diff --git a/extensions/ql-vscode/src/language-support/index.ts b/extensions/ql-vscode/src/language-support/index.ts index c6e57e6c0..9b101343b 100644 --- a/extensions/ql-vscode/src/language-support/index.ts +++ b/extensions/ql-vscode/src/language-support/index.ts @@ -5,6 +5,6 @@ export * from "./contextual/key-type"; export * from "./contextual/location-finder"; export * from "./contextual/query-resolver"; export * from "./contextual/template-provider"; -export * from "./ide-server"; +export * from "./language-client"; export * from "./language-support"; export * from "./query-editor"; diff --git a/extensions/ql-vscode/src/language-support/ide-server.ts b/extensions/ql-vscode/src/language-support/language-client.ts similarity index 75% rename from extensions/ql-vscode/src/language-support/ide-server.ts rename to extensions/ql-vscode/src/language-support/language-client.ts index 889f7667c..7b27a3fda 100644 --- a/extensions/ql-vscode/src/language-support/ide-server.ts +++ b/extensions/ql-vscode/src/language-support/language-client.ts @@ -2,32 +2,32 @@ import type { TextEditor } from "vscode"; import { ProgressLocation, window } from "vscode"; import type { StreamInfo } from "vscode-languageclient/node"; import { LanguageClient, NotificationType } from "vscode-languageclient/node"; -import { shouldDebugIdeServer, spawnServer } from "../codeql-cli/cli"; +import { shouldDebugLanguageServer, spawnServer } from "../codeql-cli/cli"; import type { QueryServerConfig } from "../config"; -import { ideServerLogger } from "../common/logging/vscode"; +import { languageServerLogger } from "../common/logging/vscode"; /** - * Managing the language server for CodeQL. + * Managing the language client and corresponding server process for CodeQL. */ /** - * Create a new CodeQL language server. + * Create a new CodeQL language client connected to a language server. */ -export function createIDEServer( +export function createLanguageClient( config: QueryServerConfig, ): CodeQLLanguageClient { return new CodeQLLanguageClient(config); } /** - * CodeQL language server. + * CodeQL language client. */ export class CodeQLLanguageClient extends LanguageClient { constructor(config: QueryServerConfig) { super( "codeQL.lsp", "CodeQL Language Server", - () => spawnIdeServer(config), + () => spawnLanguageServer(config), { documentSelector: [ { language: "ql", scheme: "file" }, @@ -38,7 +38,7 @@ export class CodeQLLanguageClient extends LanguageClient { configurationSection: "codeQL", }, // Ensure that language server exceptions are logged to the same channel as its output. - outputChannel: ideServerLogger.outputChannel, + outputChannel: languageServerLogger.outputChannel, }, true, ); @@ -55,12 +55,14 @@ export class CodeQLLanguageClient extends LanguageClient { } /** Starts a new CodeQL language server process, sending progress messages to the status bar. */ -async function spawnIdeServer(config: QueryServerConfig): Promise { +async function spawnLanguageServer( + config: QueryServerConfig, +): Promise { return window.withProgress( { title: "CodeQL language server", location: ProgressLocation.Window }, async (progressReporter, _) => { const args = ["--check-errors", "ON_CHANGE"]; - if (shouldDebugIdeServer()) { + if (shouldDebugLanguageServer()) { args.push( "-J=-agentlib:jdwp=transport=dt_socket,address=localhost:9009,server=y,suspend=n,quiet=y", ); @@ -70,11 +72,11 @@ async function spawnIdeServer(config: QueryServerConfig): Promise { "CodeQL language server", ["execute", "language-server"], args, - ideServerLogger, + languageServerLogger, (data) => - ideServerLogger.log(data.toString(), { trailingNewline: false }), + languageServerLogger.log(data.toString(), { trailingNewline: false }), (data) => - ideServerLogger.log(data.toString(), { trailingNewline: false }), + languageServerLogger.log(data.toString(), { trailingNewline: false }), progressReporter, ); return { writer: child.stdin!, reader: child.stdout! }; diff --git a/extensions/ql-vscode/src/language-support/language-support.ts b/extensions/ql-vscode/src/language-support/language-support.ts index 6d2a9a973..e73bab07f 100644 --- a/extensions/ql-vscode/src/language-support/language-support.ts +++ b/extensions/ql-vscode/src/language-support/language-support.ts @@ -18,7 +18,7 @@ export function install() { langConfig.wordPattern = new RegExp(langConfig.wordPattern); langConfig.onEnterRules = onEnterRules; langConfig.indentationRules = { - decreaseIndentPattern: /^((?!.*?\/\*).*\*\/)?\s*[\}\]].*$/, + decreaseIndentPattern: /^((?!.*?\/\*).*\*\/)?\s*[}\]].*$/, increaseIndentPattern: /^((?!\/\/).)*(\{[^}"'`]*|\([^)"'`]*|\[[^\]"'`]*)$/, }; delete langConfig.autoClosingPairs; @@ -31,18 +31,18 @@ export function install() { const onEnterRules: OnEnterRule[] = [ { // e.g. /** | */ - beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, + beforeText: /^\s*\/\*\*(?!\/)([^*]|\*(?!\/))*$/, afterText: /^\s*\*\/$/, action: { indentAction: IndentAction.IndentOutdent, appendText: " * " }, }, { // e.g. /** ...| - beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, + beforeText: /^\s*\/\*\*(?!\/)([^*]|\*(?!\/))*$/, action: { indentAction: IndentAction.None, appendText: " * " }, }, { // e.g. * ...| - beforeText: /^(\t|[ ])*[ ]\*([ ]([^\*]|\*(?!\/))*)?$/, + beforeText: /^(\t|[ ])*[ ]\*([ ]([^*]|\*(?!\/))*)?$/, // oneLineAboveText: /^(\s*(\/\*\*|\*)).*/, action: { indentAction: IndentAction.None, appendText: "* " }, }, diff --git a/extensions/ql-vscode/src/local-queries/local-queries.ts b/extensions/ql-vscode/src/local-queries/local-queries.ts index 7e42e3160..5b3e44d97 100644 --- a/extensions/ql-vscode/src/local-queries/local-queries.ts +++ b/extensions/ql-vscode/src/local-queries/local-queries.ts @@ -3,12 +3,7 @@ import type { ProgressUpdate, } from "../common/vscode/progress"; import { withProgress } from "../common/vscode/progress"; -import type { - CancellationToken, - QuickPickItem, - Range, - TabInputText, -} from "vscode"; +import type { CancellationToken, Range, TabInputText } from "vscode"; import { CancellationTokenSource, Uri, window } from "vscode"; import { TeeLogger, @@ -23,7 +18,10 @@ import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { displayQuickQuery } from "./quick-query"; import type { CoreCompletedQuery, QueryRunner } from "../query-server"; import type { QueryHistoryManager } from "../query-history/query-history-manager"; -import type { DatabaseUI } from "../databases/local-databases-ui"; +import type { + DatabaseQuickPickItem, + DatabaseUI, +} from "../databases/local-databases-ui"; import type { ResultsView } from "./results-view"; import type { DatabaseItem, @@ -55,10 +53,6 @@ import { tryGetQueryLanguage } from "../common/query-language"; import type { LanguageContextStore } from "../language-context-store"; import type { ExtensionApp } from "../common/vscode/vscode-app"; -interface DatabaseQuickPickItem extends QuickPickItem { - databaseItem: DatabaseItem; -} - export enum QuickEvalType { None, QuickEval, diff --git a/extensions/ql-vscode/src/model-editor/languages/ruby/access-paths.ts b/extensions/ql-vscode/src/model-editor/languages/ruby/access-paths.ts new file mode 100644 index 000000000..f8942cee1 --- /dev/null +++ b/extensions/ql-vscode/src/model-editor/languages/ruby/access-paths.ts @@ -0,0 +1,41 @@ +export function parseRubyMethodFromPath(path: string): string { + const match = path.match(/Method\[([^\]]+)].*/); + if (match) { + return match[1]; + } else { + return ""; + } +} + +export function parseRubyAccessPath(path: string): { + methodName: string; + path: string; +} { + const match = path.match(/Method\[([^\]]+)]\.(.*)/); + if (match) { + return { methodName: match[1], path: match[2] }; + } else { + return { methodName: "", path: "" }; + } +} + +export function rubyMethodSignature(typeName: string, methodName: string) { + return `${typeName}#${methodName}`; +} + +export function rubyMethodPath(methodName: string) { + if (methodName === "") { + return ""; + } + + return `Method[${methodName}]`; +} + +export function rubyPath(methodName: string, path: string) { + const methodPath = rubyMethodPath(methodName); + if (methodPath === "") { + return path; + } + + return `${methodPath}.${path}`; +} diff --git a/extensions/ql-vscode/src/model-editor/languages/ruby/index.ts b/extensions/ql-vscode/src/model-editor/languages/ruby/index.ts index a4f33c13d..45c3f8a57 100644 --- a/extensions/ql-vscode/src/model-editor/languages/ruby/index.ts +++ b/extensions/ql-vscode/src/model-editor/languages/ruby/index.ts @@ -4,48 +4,13 @@ import { Mode } from "../../shared/mode"; import { parseGenerateModelResults } from "./generate"; import type { MethodArgument } from "../../method"; import { getArgumentsList } from "../../method"; - -function parseRubyMethodFromPath(path: string): string { - const match = path.match(/Method\[([^\]]+)].*/); - if (match) { - return match[1]; - } else { - return ""; - } -} - -function parseRubyAccessPath(path: string): { - methodName: string; - path: string; -} { - const match = path.match(/Method\[([^\]]+)]\.(.*)/); - if (match) { - return { methodName: match[1], path: match[2] }; - } else { - return { methodName: "", path: "" }; - } -} - -function rubyMethodSignature(typeName: string, methodName: string) { - return `${typeName}#${methodName}`; -} - -function rubyMethodPath(methodName: string) { - if (methodName === "") { - return ""; - } - - return `Method[${methodName}]`; -} - -function rubyPath(methodName: string, path: string) { - const methodPath = rubyMethodPath(methodName); - if (methodPath === "") { - return path; - } - - return `${methodPath}.${path}`; -} +import { + parseRubyAccessPath, + parseRubyMethodFromPath, + rubyMethodPath, + rubyMethodSignature, + rubyPath, +} from "./access-paths"; export const ruby: ModelsAsDataLanguage = { availableModes: [Mode.Framework], diff --git a/extensions/ql-vscode/src/model-editor/shared/access-paths.ts b/extensions/ql-vscode/src/model-editor/shared/access-paths.ts new file mode 100644 index 000000000..ba0c22006 --- /dev/null +++ b/extensions/ql-vscode/src/model-editor/shared/access-paths.ts @@ -0,0 +1,128 @@ +/** + * This file contains functions for parsing and validating access paths. + * + * This intentionally does not simply split by '.' since tokens may contain dots, + * e.g. `Field[foo.Bar.x]`. Instead, it uses some simple parsing to match valid tokens. + * + * Valid syntax was determined based on this file: + * https://github.com/github/codeql/blob/a04830b8b2d3e5f7df8e1f80f06c020b987a89a3/ruby/ql/lib/codeql/ruby/dataflow/internal/AccessPathSyntax.qll + * + * In contrast to that file, we do not use a regex for parsing to allow us to be more lenient. + * For example, we can parse partial access paths such as `Field[foo.Bar.x` without error. + */ + +/** + * A range of characters in an access path. The start position is inclusive, the end position is exclusive. + */ +type AccessPathRange = { + /** + * Zero-based index of the first character of the token. + */ + start: number; + /** + * Zero-based index of the character after the last character of the token. + */ + end: number; +}; + +/** + * A token in an access path. For example, `Argument[foo]` is a token. + */ +type AccessPartToken = { + text: string; + range: AccessPathRange; +}; + +/** + * Parses an access path into tokens. + * + * @param path The access path to parse. + * @returns An array of tokens. + */ +export function parseAccessPathTokens(path: string): AccessPartToken[] { + const parts: AccessPartToken[] = []; + + let currentPart = ""; + let currentPathStart = 0; + // Keep track of the number of brackets we can parse the path correctly when it contains + // nested brackets such as `Argument[foo[bar].test].Element`. + let bracketCounter = 0; + for (let i = 0; i < path.length; i++) { + const c = path[i]; + + if (c === "[") { + bracketCounter++; + } else if (c === "]") { + bracketCounter--; + } else if (c === "." && bracketCounter === 0) { + // A part ends when we encounter a dot that is not inside brackets. + parts.push({ + text: currentPart, + range: { + start: currentPathStart, + end: i, + }, + }); + currentPart = ""; + currentPathStart = i + 1; + continue; + } + + currentPart += c; + } + + // The last part should not be followed by a dot, so we need to add it manually. + // If the path is empty, such as for `Argument[foo].`, then this is still correct + // since the `validateAccessPath` function will check that none of the tokens are + // empty. + parts.push({ + text: currentPart, + range: { + start: currentPathStart, + end: path.length, + }, + }); + + return parts; +} + +// Regex for a single part of the access path +const tokenRegex = /^(\w+)(?:\[([^\]]*)])?$/; + +type AccessPathDiagnostic = { + range: AccessPathRange; + message: string; +}; + +/** + * Validates an access path and returns any errors. This requires that the path is a valid path + * and does not allow partial access paths. + * + * @param path The access path to validate. + * @returns An array of diagnostics for any errors in the access path. + */ +export function validateAccessPath(path: string): AccessPathDiagnostic[] { + if (path === "") { + return []; + } + + const tokens = parseAccessPathTokens(path); + + return tokens + .map((token): AccessPathDiagnostic | null => { + if (tokenRegex.test(token.text)) { + return null; + } + + let message = "Invalid access path"; + if (token.range.start === token.range.end) { + message = "Unexpected empty token"; + } + + return { + range: token.range, + message, + }; + }) + .filter((token): token is AccessPathDiagnostic => token !== null); +} diff --git a/extensions/ql-vscode/src/query-history/query-history-manager.ts b/extensions/ql-vscode/src/query-history/query-history-manager.ts index 61de46c56..ea5806a05 100644 --- a/extensions/ql-vscode/src/query-history/query-history-manager.ts +++ b/extensions/ql-vscode/src/query-history/query-history-manager.ts @@ -373,6 +373,11 @@ export class QueryHistoryManager extends DisposableObject { const variantAnalysisAddedSubscription = this.variantAnalysisManager.onVariantAnalysisAdded( async (variantAnalysis) => { + if (variantAnalysis.queries !== undefined) { + // This is a variant analysis that contains multiple queries, which + // is not fully supported yet. So we ignore it from the query history. + return; + } this.addQuery({ t: "variant-analysis", status: QueryStatus.InProgress, diff --git a/extensions/ql-vscode/src/query-testing/test-adapter.ts b/extensions/ql-vscode/src/query-testing/test-adapter.ts deleted file mode 100644 index eef9a2672..000000000 --- a/extensions/ql-vscode/src/query-testing/test-adapter.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { extname } from "path"; -import type { Event, WorkspaceFolder } from "vscode"; -import { CancellationTokenSource, EventEmitter } from "vscode"; -import type { - TestAdapter, - TestEvent, - TestHub, - TestInfo, - TestLoadFinishedEvent, - TestLoadStartedEvent, - TestRunFinishedEvent, - TestRunStartedEvent, - TestSuiteEvent, - TestSuiteInfo, -} from "vscode-test-adapter-api"; -import { TestAdapterRegistrar } from "vscode-test-adapter-util"; -import { QLTestDiscovery } from "./qltest-discovery"; -import { DisposableObject } from "../common/disposable-object"; -import type { CodeQLCliServer, TestCompleted } from "../codeql-cli/cli"; -import { testLogger } from "../common/logging/vscode"; -import type { TestRunner } from "./test-runner"; -import type { FileTreeNode } from "../common/file-tree-nodes"; -import { FileTreeDirectory, FileTreeLeaf } from "../common/file-tree-nodes"; - -/** - * Get the full path of the `.expected` file for the specified QL test. - * @param testPath The full path to the test file. - */ -export function getExpectedFile(testPath: string): string { - return getTestOutputFile(testPath, ".expected"); -} - -/** - * Get the full path of the `.actual` file for the specified QL test. - * @param testPath The full path to the test file. - */ -export function getActualFile(testPath: string): string { - return getTestOutputFile(testPath, ".actual"); -} - -/** - * Gets the the full path to a particular output file of the specified QL test. - * @param testPath The full path to the QL test. - * @param extension The file extension of the output file. - */ -function getTestOutputFile(testPath: string, extension: string): string { - return changeExtension(testPath, extension); -} - -/** - * A factory service that creates `QLTestAdapter` objects for workspace folders on demand. - */ -export class QLTestAdapterFactory extends DisposableObject { - constructor( - testHub: TestHub, - testRunner: TestRunner, - cliServer: CodeQLCliServer, - ) { - super(); - - // this will register a QLTestAdapter for each WorkspaceFolder - this.push( - new TestAdapterRegistrar( - testHub, - (workspaceFolder) => - new QLTestAdapter(workspaceFolder, testRunner, cliServer), - ), - ); - } -} - -/** - * Change the file extension of the specified path. - * @param p The original file path. - * @param ext The new extension, including the `.`. - */ -function changeExtension(p: string, ext: string): string { - return p.slice(0, -extname(p).length) + ext; -} - -/** - * Test adapter for QL tests. - */ -export class QLTestAdapter extends DisposableObject implements TestAdapter { - private readonly qlTestDiscovery: QLTestDiscovery; - private readonly _tests = this.push( - new EventEmitter(), - ); - private readonly _testStates = this.push( - new EventEmitter< - TestRunStartedEvent | TestRunFinishedEvent | TestSuiteEvent | TestEvent - >(), - ); - private readonly _autorun = this.push(new EventEmitter()); - private runningTask?: CancellationTokenSource = undefined; - - constructor( - public readonly workspaceFolder: WorkspaceFolder, - private readonly testRunner: TestRunner, - cliServer: CodeQLCliServer, - ) { - super(); - - this.qlTestDiscovery = this.push( - new QLTestDiscovery(workspaceFolder, cliServer), - ); - void this.qlTestDiscovery.refresh(); - - this.push(this.qlTestDiscovery.onDidChangeTests(this.discoverTests, this)); - } - - public get tests(): Event { - return this._tests.event; - } - - public get testStates(): Event< - TestRunStartedEvent | TestRunFinishedEvent | TestSuiteEvent | TestEvent - > { - return this._testStates.event; - } - - public get autorun(): Event | undefined { - return this._autorun.event; - } - - private static createTestOrSuiteInfos( - testNodes: readonly FileTreeNode[], - ): Array { - return testNodes.map((childNode) => { - return QLTestAdapter.createTestOrSuiteInfo(childNode); - }); - } - - private static createTestOrSuiteInfo( - testNode: FileTreeNode, - ): TestSuiteInfo | TestInfo { - if (testNode instanceof FileTreeLeaf) { - return QLTestAdapter.createTestInfo(testNode); - } else if (testNode instanceof FileTreeDirectory) { - return QLTestAdapter.createTestSuiteInfo(testNode, testNode.name); - } else { - throw new Error("Unexpected test type."); - } - } - - private static createTestInfo(testFile: FileTreeLeaf): TestInfo { - return { - type: "test", - id: testFile.path, - label: testFile.name, - tooltip: testFile.path, - file: testFile.path, - }; - } - - private static createTestSuiteInfo( - testDirectory: FileTreeDirectory, - label: string, - ): TestSuiteInfo { - return { - type: "suite", - id: testDirectory.path, - label, - children: QLTestAdapter.createTestOrSuiteInfos(testDirectory.children), - tooltip: testDirectory.path, - }; - } - - public async load(): Promise { - this.discoverTests(); - } - - private discoverTests(): void { - this._tests.fire({ type: "started" } as TestLoadStartedEvent); - - const testDirectory = this.qlTestDiscovery.testDirectory; - let testSuite: TestSuiteInfo | undefined; - if (testDirectory?.children.length) { - const children = QLTestAdapter.createTestOrSuiteInfos( - testDirectory.children, - ); - testSuite = { - type: "suite", - label: "CodeQL", - id: testDirectory.path, - children, - }; - } - this._tests.fire({ - type: "finished", - suite: testSuite, - } as TestLoadFinishedEvent); - } - - public async run(tests: string[]): Promise { - if (this.runningTask !== undefined) { - throw new Error("Tests already running."); - } - - testLogger.outputChannel.clear(); - testLogger.outputChannel.show(true); - - this.runningTask = this.track(new CancellationTokenSource()); - const token = this.runningTask.token; - - this._testStates.fire({ - type: "started", - tests, - } as TestRunStartedEvent); - - await this.testRunner.run(tests, testLogger, token, (event) => - this.processTestEvent(event), - ); - - this._testStates.fire({ type: "finished" } as TestRunFinishedEvent); - this.clearTask(); - } - - private clearTask(): void { - if (this.runningTask !== undefined) { - const runningTask = this.runningTask; - this.runningTask = undefined; - this.disposeAndStopTracking(runningTask); - } - } - - public cancel(): void { - if (this.runningTask !== undefined) { - void testLogger.log("Cancelling test run..."); - this.runningTask.cancel(); - this.clearTask(); - } - } - - private async processTestEvent(event: TestCompleted): Promise { - const state = event.pass - ? "passed" - : event.messages?.length - ? "errored" - : "failed"; - let message: string | undefined; - if (event.failureDescription || event.diff?.length) { - message = - event.failureStage === "RESULT" - ? [ - "", - `${state}: ${event.test}`, - event.failureDescription || event.diff?.join("\n"), - "", - ].join("\n") - : [ - "", - `${event.failureStage?.toLowerCase() ?? "unknown stage"} error: ${ - event.test - }`, - event.failureDescription || - `${event.messages[0].severity}: ${event.messages[0].message}`, - "", - ].join("\n"); - void testLogger.log(message); - } - this._testStates.fire({ - type: "test", - state, - test: event.test, - message, - decorations: event.messages?.map((msg) => ({ - line: msg.position.line, - message: msg.message, - })), - }); - } -} diff --git a/extensions/ql-vscode/src/query-testing/test-manager-base.ts b/extensions/ql-vscode/src/query-testing/test-manager-base.ts deleted file mode 100644 index 75d92d4f4..000000000 --- a/extensions/ql-vscode/src/query-testing/test-manager-base.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { copy, createFile, lstat, pathExists } from "fs-extra"; -import type { TestUICommands } from "../common/commands"; -import { DisposableObject } from "../common/disposable-object"; -import { getActualFile, getExpectedFile } from "./test-adapter"; -import type { TestItem, TextDocumentShowOptions } from "vscode"; -import { Uri, window } from "vscode"; -import { basename } from "path"; -import type { App } from "../common/app"; -import type { TestTreeNode } from "./test-tree-node"; - -type TestNode = TestTreeNode | TestItem; - -/** - * Base class for both the legacy and new test services. Implements commands that are common to - * both. - */ -export abstract class TestManagerBase extends DisposableObject { - protected constructor(private readonly app: App) { - super(); - } - - public getCommands(): TestUICommands { - return { - "codeQLTests.showOutputDifferences": - this.showOutputDifferences.bind(this), - "codeQLTests.acceptOutput": this.acceptOutput.bind(this), - "codeQLTests.acceptOutputContextTestItem": this.acceptOutput.bind(this), - }; - } - - /** Override to compute the path of the test file from the selected node. */ - protected abstract getTestPath(node: TestNode): string; - - private async acceptOutput(node: TestNode): Promise { - const testPath = this.getTestPath(node); - const stat = await lstat(testPath); - if (stat.isFile()) { - const expectedPath = getExpectedFile(testPath); - const actualPath = getActualFile(testPath); - await copy(actualPath, expectedPath, { overwrite: true }); - } - } - - private async showOutputDifferences(node: TestNode): Promise { - const testId = this.getTestPath(node); - const stat = await lstat(testId); - if (stat.isFile()) { - const expectedPath = getExpectedFile(testId); - const expectedUri = Uri.file(expectedPath); - const actualPath = getActualFile(testId); - const options: TextDocumentShowOptions = { - preserveFocus: true, - preview: true, - }; - - if (!(await pathExists(expectedPath))) { - // Just create a new file. - await createFile(expectedPath); - } - - if (await pathExists(actualPath)) { - const actualUri = Uri.file(actualPath); - await this.app.commands.execute( - "vscode.diff", - expectedUri, - actualUri, - `Expected vs. Actual for ${basename(testId)}`, - options, - ); - } else { - await window.showTextDocument(expectedUri, options); - } - } - } -} diff --git a/extensions/ql-vscode/src/query-testing/test-manager.ts b/extensions/ql-vscode/src/query-testing/test-manager.ts index 12381c6d9..ff1a91d88 100644 --- a/extensions/ql-vscode/src/query-testing/test-manager.ts +++ b/extensions/ql-vscode/src/query-testing/test-manager.ts @@ -1,10 +1,11 @@ -import { readFile } from "fs-extra"; +import { copy, createFile, lstat, pathExists, readFile } from "fs-extra"; import type { CancellationToken, TestController, TestItem, TestRun, TestRunRequest, + TextDocumentShowOptions, WorkspaceFolder, WorkspaceFoldersChangeEvent, } from "vscode"; @@ -15,6 +16,7 @@ import { TestRunProfileKind, Uri, tests, + window, workspace, } from "vscode"; import { DisposableObject } from "../common/disposable-object"; @@ -23,11 +25,46 @@ import type { CodeQLCliServer } from "../codeql-cli/cli"; import { getErrorMessage } from "../common/helpers-pure"; import type { BaseLogger, LogOptions } from "../common/logging"; import type { TestRunner } from "./test-runner"; -import { TestManagerBase } from "./test-manager-base"; import type { App } from "../common/app"; import { isWorkspaceFolderOnDisk } from "../common/vscode/workspace-folders"; import type { FileTreeNode } from "../common/file-tree-nodes"; import { FileTreeDirectory, FileTreeLeaf } from "../common/file-tree-nodes"; +import type { TestUICommands } from "../common/commands"; +import { basename, extname } from "path"; + +/** + * Get the full path of the `.expected` file for the specified QL test. + * @param testPath The full path to the test file. + */ +function getExpectedFile(testPath: string): string { + return getTestOutputFile(testPath, ".expected"); +} + +/** + * Get the full path of the `.actual` file for the specified QL test. + * @param testPath The full path to the test file. + */ +function getActualFile(testPath: string): string { + return getTestOutputFile(testPath, ".actual"); +} + +/** + * Gets the the full path to a particular output file of the specified QL test. + * @param testPath The full path to the QL test. + * @param extension The file extension of the output file. + */ +function getTestOutputFile(testPath: string, extension: string): string { + return changeExtension(testPath, extension); +} + +/** + * Change the file extension of the specified path. + * @param p The original file path. + * @param ext The new extension, including the `.`. + */ +function changeExtension(p: string, ext: string): string { + return p.slice(0, -extname(p).length) + ext; +} /** * Returns the complete text content of the specified file. If there is an error reading the file, @@ -108,7 +145,7 @@ class WorkspaceFolderHandler extends DisposableObject { * Service that populates the VS Code "Test Explorer" panel for CodeQL, and handles running and * debugging of tests. */ -export class TestManager extends TestManagerBase { +export class TestManager extends DisposableObject { /** * Maps from each workspace folder being tracked to the `WorkspaceFolderHandler` responsible for * tracking it. @@ -119,7 +156,7 @@ export class TestManager extends TestManagerBase { >(); public constructor( - app: App, + private readonly app: App, private readonly testRunner: TestRunner, private readonly cliServer: CodeQLCliServer, // Having this as a parameter with a default value makes passing in a mock easier. @@ -128,7 +165,7 @@ export class TestManager extends TestManagerBase { "CodeQL Tests", ), ) { - super(app); + super(); this.testController.createRunProfile( "Run", @@ -151,6 +188,15 @@ export class TestManager extends TestManagerBase { super.dispose(); } + public getCommands(): TestUICommands { + return { + "codeQLTests.showOutputDifferences": + this.showOutputDifferences.bind(this), + "codeQLTests.acceptOutput": this.acceptOutput.bind(this), + "codeQLTests.acceptOutputContextTestItem": this.acceptOutput.bind(this), + }; + } + protected getTestPath(node: TestItem): string { if (node.uri === undefined || node.uri.scheme !== "file") { throw new Error("Selected test is not a CodeQL test."); @@ -158,6 +204,48 @@ export class TestManager extends TestManagerBase { return node.uri.fsPath; } + private async acceptOutput(node: TestItem): Promise { + const testPath = this.getTestPath(node); + const stat = await lstat(testPath); + if (stat.isFile()) { + const expectedPath = getExpectedFile(testPath); + const actualPath = getActualFile(testPath); + await copy(actualPath, expectedPath, { overwrite: true }); + } + } + + private async showOutputDifferences(node: TestItem): Promise { + const testId = this.getTestPath(node); + const stat = await lstat(testId); + if (stat.isFile()) { + const expectedPath = getExpectedFile(testId); + const expectedUri = Uri.file(expectedPath); + const actualPath = getActualFile(testId); + const options: TextDocumentShowOptions = { + preserveFocus: true, + preview: true, + }; + + if (!(await pathExists(expectedPath))) { + // Just create a new file. + await createFile(expectedPath); + } + + if (await pathExists(actualPath)) { + const actualUri = Uri.file(actualPath); + await this.app.commands.execute( + "vscode.diff", + expectedUri, + actualUri, + `Expected vs. Actual for ${basename(testId)}`, + options, + ); + } else { + await window.showTextDocument(expectedUri, options); + } + } + } + /** Start tracking tests in the specified workspace folders. */ private startTrackingWorkspaceFolders( workspaceFolders: readonly WorkspaceFolder[], diff --git a/extensions/ql-vscode/src/query-testing/test-tree-node.ts b/extensions/ql-vscode/src/query-testing/test-tree-node.ts deleted file mode 100644 index 0cd6a6d54..000000000 --- a/extensions/ql-vscode/src/query-testing/test-tree-node.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { TestSuiteInfo, TestInfo } from "vscode-test-adapter-api"; - -/** - * Tree view node for a test, suite, or collection. This object is passed as the argument to the - * command handler of a context menu item for a tree view item. - */ -export interface TestTreeNode { - readonly info: TestSuiteInfo | TestInfo; -} diff --git a/extensions/ql-vscode/src/query-testing/test-ui.ts b/extensions/ql-vscode/src/query-testing/test-ui.ts deleted file mode 100644 index c45fc01a9..000000000 --- a/extensions/ql-vscode/src/query-testing/test-ui.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { - TestHub, - TestController, - TestAdapter, - TestRunStartedEvent, - TestRunFinishedEvent, - TestEvent, - TestSuiteEvent, -} from "vscode-test-adapter-api"; -import type { TestTreeNode } from "./test-tree-node"; -import { DisposableObject } from "../common/disposable-object"; -import { QLTestAdapter } from "./test-adapter"; -import type { App } from "../common/app"; -import { TestManagerBase } from "./test-manager-base"; - -type VSCodeTestEvent = - | TestRunStartedEvent - | TestRunFinishedEvent - | TestSuiteEvent - | TestEvent; - -/** - * Test event listener. Currently unused, but left in to keep the plumbing hooked up for future use. - */ -class QLTestListener extends DisposableObject { - constructor(adapter: TestAdapter) { - super(); - - this.push(adapter.testStates(this.onTestStatesEvent, this)); - } - - private onTestStatesEvent(_e: VSCodeTestEvent): void { - /**/ - } -} - -/** - * Service that implements all UI and commands for QL tests. - */ -export class TestUIService extends TestManagerBase implements TestController { - private readonly listeners: Map = new Map(); - - public constructor( - app: App, - private readonly testHub: TestHub, - ) { - super(app); - - testHub.registerTestController(this); - } - - public dispose(): void { - this.testHub.unregisterTestController(this); - - super.dispose(); - } - - public registerTestAdapter(adapter: TestAdapter): void { - this.listeners.set(adapter, new QLTestListener(adapter)); - } - - public unregisterTestAdapter(adapter: TestAdapter): void { - if (adapter instanceof QLTestAdapter) { - this.listeners.delete(adapter); - } - } - - protected getTestPath(node: TestTreeNode): string { - return node.info.id; - } -} diff --git a/extensions/ql-vscode/src/stories/common/SuggestBox.stories.tsx b/extensions/ql-vscode/src/stories/common/SuggestBox.stories.tsx new file mode 100644 index 000000000..b7acb398f --- /dev/null +++ b/extensions/ql-vscode/src/stories/common/SuggestBox.stories.tsx @@ -0,0 +1,168 @@ +import type { Meta, StoryFn } from "@storybook/react"; + +import { styled } from "styled-components"; + +import { Codicon } from "../../view/common"; +import { SuggestBox as SuggestBoxComponent } from "../../view/common/SuggestBox/SuggestBox"; +import { useCallback, useState } from "react"; +import type { Diagnostic } from "../../view/common/SuggestBox/diagnostics"; + +export default { + title: "Suggest Box", + component: SuggestBoxComponent, +} as Meta; + +type StoryOption = { + label: string; + icon: string; + details?: string; + value: string; + followup?: StoryOption[]; +}; + +const Template: StoryFn> = (args) => { + const [value, setValue] = useState(""); + + const handleChange = useCallback( + (value: string) => { + args.onChange(value); + setValue(value); + }, + [args], + ); + + return ( + + {...args} + value={value} + onChange={handleChange} + /> + ); +}; + +const Icon = styled(Codicon)` + margin-right: 4px; + color: var(--vscode-symbolIcon-fieldForeground); + font-size: 16px; +`; + +const suggestedOptions: StoryOption[] = [ + { + label: "Argument[self]", + icon: "symbol-class", + details: "sqlite3.SQLite3::Database", + value: "Argument[self]", + }, + { + label: "Argument[0]", + icon: "symbol-parameter", + details: "name", + value: "Argument[0]", + followup: [ + { + label: "Element[0]", + icon: "symbol-field", + value: "Argument[0].Element[0]", + details: "first character", + }, + { + label: "Element[1]", + icon: "symbol-field", + value: "Argument[0].Element[1]", + details: "second character", + }, + { + label: "Element[any]", + icon: "symbol-field", + value: "Argument[0].Element[any]", + details: "any character", + }, + ], + }, + { + label: "Argument[1]", + icon: "symbol-parameter", + details: "arity", + value: "Argument[1]", + }, + { + label: "Argument[text_rep:]", + icon: "symbol-parameter", + details: "text_rep:", + value: "Argument[text_rep:]", + }, + { + label: "Argument[block]", + icon: "symbol-parameter", + details: "&block", + value: "Argument[block]", + followup: [ + { + label: "Parameter[0]", + icon: "symbol-parameter", + value: "Argument[block].Parameter[0]", + details: "val", + followup: [ + { + label: "Element[:query]", + icon: "symbol-key", + value: "Argument[block].Parameter[0].Element[:query]", + }, + { + label: "Element[:parameters]", + icon: "symbol-key", + value: "Argument[block].Parameter[0].Element[:parameters]", + }, + ], + }, + { + label: "Parameter[1]", + icon: "symbol-parameter", + value: "Argument[block].Parameter[1]", + details: "context", + followup: [ + { + label: "Field[@query]", + icon: "symbol-field", + value: "Argument[block].Parameter[1].Field[@query]", + }, + ], + }, + ], + }, + { + label: "ReturnValue", + icon: "symbol-variable", + details: undefined, + value: "ReturnValue", + }, +]; + +export const AccessPath = Template.bind({}); +AccessPath.args = { + options: suggestedOptions, + parseValueToTokens: (value: string) => value.split("."), + validateValue: (value: string) => { + let index = value.indexOf("|"); + + const diagnostics: Diagnostic[] = []; + + while (index !== -1) { + // For testing in this Storybook, disallow pipe characters to avoid a dependency on the + // real access path validation. + index = value.indexOf("|", index + 1); + + diagnostics.push({ + message: "This cannot contain |", + range: { + start: index, + end: index + 1, + }, + }); + } + + return diagnostics; + }, + getIcon: (option: StoryOption) => , + getDetails: (option: StoryOption) => option.details, +}; diff --git a/extensions/ql-vscode/src/variant-analysis/export-results.ts b/extensions/ql-vscode/src/variant-analysis/export-results.ts index c1da3d75b..c36c0fb33 100644 --- a/extensions/ql-vscode/src/variant-analysis/export-results.ts +++ b/extensions/ql-vscode/src/variant-analysis/export-results.ts @@ -356,7 +356,7 @@ async function exportToLocalMarkdown( // This needs to use .then to ensure we aren't keeping the progress notification open. We shouldn't await the // "Open exported results" button click. void showInformationMessageWithAction( - `Variant analysis results exported to \"${exportedResultsPath}\".`, + `Variant analysis results exported to "${exportedResultsPath}".`, "Open exported results", ).then(async (shouldOpenExportedResults) => { if (!shouldOpenExportedResults) { diff --git a/extensions/ql-vscode/src/variant-analysis/run-remote-query.ts b/extensions/ql-vscode/src/variant-analysis/run-remote-query.ts index 421dc54e7..62ced7fe4 100644 --- a/extensions/ql-vscode/src/variant-analysis/run-remote-query.ts +++ b/extensions/ql-vscode/src/variant-analysis/run-remote-query.ts @@ -3,6 +3,7 @@ import { Uri, window } from "vscode"; import { relative, join, sep, dirname, parse, basename } from "path"; import { dump, load } from "js-yaml"; import { copy, writeFile, readFile, mkdirp } from "fs-extra"; +import type { DirectoryResult } from "tmp-promise"; import { dir, tmpName } from "tmp-promise"; import { tmpDir } from "../tmp-dir"; import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; @@ -37,6 +38,7 @@ import type { QueryLanguage } from "../common/query-language"; import { tryGetQueryMetadata } from "../codeql-cli/query-metadata"; import { askForLanguage, findLanguage } from "../codeql-cli/query-language"; import type { QlPackFile } from "../packaging/qlpack-file"; +import { expandShortPaths } from "../common/short-paths"; /** * Well-known names for the query pack used by the server. @@ -58,21 +60,52 @@ interface GeneratedQueryPack { async function generateQueryPack( cliServer: CodeQLCliServer, queryFile: string, - queryPackDir: string, + tmpDir: RemoteQueryTempDir, ): Promise { const originalPackRoot = await findPackRoot(queryFile); const packRelativePath = relative(originalPackRoot, queryFile); - const targetQueryFileName = join(queryPackDir, packRelativePath); const workspaceFolders = getOnDiskWorkspaceFolders(); + const extensionPacks = await getExtensionPacksToInject( + cliServer, + workspaceFolders, + ); - let language: QueryLanguage | undefined; + const mustSynthesizePack = + (await getQlPackPath(originalPackRoot)) === undefined; + const cliSupportsMrvaPackCreate = + await cliServer.cliConstraints.supportsMrvaPackCreate(); - // Check if the query is already in a query pack. - // If so, copy the entire query pack to the temporary directory. - // Otherwise, copy only the query file to the temporary directory - // and generate a synthetic query pack. - if (await getQlPackPath(originalPackRoot)) { - // don't include ql files. We only want the queryFile to be copied. + const language: QueryLanguage | undefined = mustSynthesizePack + ? await askForLanguage(cliServer) // open popup to ask for language if not already hardcoded + : await findLanguage(cliServer, Uri.file(queryFile)); + if (!language) { + throw new UserCancellationException("Could not determine language"); + } + + let queryPackDir: string; + let needsInstall: boolean; + if (mustSynthesizePack) { + // This section applies whether or not the CLI supports MRVA pack creation directly. + + queryPackDir = tmpDir.queryPackDir; + + // Synthesize a query pack for the query. + // copy only the query file to the query pack directory + // and generate a synthetic query pack + await createNewQueryPack( + queryFile, + queryPackDir, + language, + packRelativePath, + ); + // Clear the cliServer cache so that the previous qlpack text is purged from the CLI. + await cliServer.clearCache(); + + // Install packs, since we just synthesized a dependency on the language's standard library. + needsInstall = true; + } else if (!cliSupportsMrvaPackCreate) { + // We need to copy the query pack to a temporary directory and then fix it up to work with MRVA. + queryPackDir = tmpDir.queryPackDir; await copyExistingQueryPack( cliServer, originalPackRoot, @@ -81,52 +114,55 @@ async function generateQueryPack( packRelativePath, ); - language = await findLanguage(cliServer, Uri.file(targetQueryFileName)); + // We should already have all the dependencies available, but these older versions of the CLI + // have a bug where they will not search `--additional-packs` during validation in `codeql pack bundle`. + // Installing the packs will ensure that any extension packs get put in the right place. + needsInstall = true; } else { - // open popup to ask for language if not already hardcoded - language = await askForLanguage(cliServer); - - // copy only the query file to the query pack directory - // and generate a synthetic query pack - await createNewQueryPack( - queryFile, - queryPackDir, - targetQueryFileName, - language, - packRelativePath, - ); - } - if (!language) { - throw new UserCancellationException("Could not determine language."); + // The CLI supports creating a MRVA query pack directly from the source pack. + queryPackDir = originalPackRoot; + // We expect any dependencies to be available already. + needsInstall = false; } - // Clear the cliServer cache so that the previous qlpack text is purged from the CLI. - await cliServer.clearCache(); + if (needsInstall) { + // Install the dependencies of the synthesized query pack. + await cliServer.packInstall(queryPackDir, { + workspaceFolders, + }); - let precompilationOpts: string[] = []; - if (await cliServer.cliConstraints.usesGlobalCompilationCache()) { - precompilationOpts = ["--qlx"]; - } else { - const ccache = join(originalPackRoot, ".cache"); + // Clear the CLI cache so that the most recent qlpack lock file is used. + await cliServer.clearCache(); + } + + let precompilationOpts: string[]; + if (cliSupportsMrvaPackCreate) { precompilationOpts = [ - "--qlx", - "--no-default-compilation-cache", - `--compilation-cache=${ccache}`, + "--mrva", + "--query", + join(queryPackDir, packRelativePath), + // We need to specify the extension packs as dependencies so that they are included in the MRVA pack. + // The version range doesn't matter, since they'll always be found by source lookup. + ...extensionPacks.map((p) => `--extension-pack=${p}@*`), ]; + } else { + if (await cliServer.cliConstraints.usesGlobalCompilationCache()) { + precompilationOpts = ["--qlx"]; + } else { + const cache = join(originalPackRoot, ".cache"); + precompilationOpts = [ + "--qlx", + "--no-default-compilation-cache", + `--compilation-cache=${cache}`, + ]; + } + + if (extensionPacks.length > 0) { + await addExtensionPacksAsDependencies(queryPackDir, extensionPacks); + } } - if (await cliServer.useExtensionPacks()) { - await injectExtensionPacks(cliServer, queryPackDir, workspaceFolders); - } - - await cliServer.packInstall(queryPackDir, { - workspaceFolders, - }); - - // Clear the CLI cache so that the most recent qlpack lock file is used. - await cliServer.clearCache(); - - const bundlePath = await getPackedBundlePath(queryPackDir); + const bundlePath = tmpDir.bundleFile; void extLogger.log( `Compiling and bundling query pack from ${queryPackDir} to ${bundlePath}. (This may take a while.)`, ); @@ -134,6 +170,7 @@ async function generateQueryPack( queryPackDir, workspaceFolders, bundlePath, + tmpDir.compiledPackDir, precompilationOpts, ); const base64Pack = (await readFile(bundlePath)).toString("base64"); @@ -146,11 +183,11 @@ async function generateQueryPack( async function createNewQueryPack( queryFile: string, queryPackDir: string, - targetQueryFileName: string, language: string | undefined, packRelativePath: string, ) { void extLogger.log(`Copying ${queryFile} to ${queryPackDir}`); + const targetQueryFileName = join(queryPackDir, packRelativePath); await copy(queryFile, targetQueryFileName); void extLogger.log("Generating synthetic query pack"); const syntheticQueryPack = { @@ -242,19 +279,37 @@ function isFileSystemRoot(dir: string): boolean { return pathObj.root === dir && pathObj.base === ""; } -async function createRemoteQueriesTempDirectory() { - const remoteQueryDir = await dir({ +interface RemoteQueryTempDir { + remoteQueryDir: DirectoryResult; + queryPackDir: string; + compiledPackDir: string; + bundleFile: string; +} + +async function createRemoteQueriesTempDirectory(): Promise { + const shortRemoteQueryDir = await dir({ dir: tmpDir.name, unsafeCleanup: true, }); + // Expand 8.3 filenames here to work around a CLI bug where `codeql pack bundle` produces an empty + // archive if the pack path contains any 8.3 components. + const remoteQueryDir = { + ...shortRemoteQueryDir, + path: await expandShortPaths(shortRemoteQueryDir.path, extLogger), + }; const queryPackDir = join(remoteQueryDir.path, "query-pack"); await mkdirp(queryPackDir); - return { remoteQueryDir, queryPackDir }; + const compiledPackDir = join(remoteQueryDir.path, "compiled-pack"); + const bundleFile = await expandShortPaths( + await getPackedBundlePath(tmpDir.name), + extLogger, + ); + return { remoteQueryDir, queryPackDir, compiledPackDir, bundleFile }; } -async function getPackedBundlePath(queryPackDir: string) { +async function getPackedBundlePath(remoteQueryDir: string): Promise { return tmpName({ - dir: dirname(queryPackDir), + dir: remoteQueryDir, postfix: "generated.tgz", prefix: "qlpack", }); @@ -274,12 +329,20 @@ interface PreparedRemoteQuery { export async function prepareRemoteQueryRun( cliServer: CodeQLCliServer, credentials: Credentials, - uri: Uri | undefined, + uris: Uri[], progress: ProgressCallback, token: CancellationToken, dbManager: DbManager, ): Promise { - if (!uri?.fsPath.endsWith(".ql")) { + if (uris.length !== 1) { + // For now we only support a single file, but we're aiming + // to support multiple files in the near future. + throw Error("Exactly one query file must be selected."); + } + + const uri = uris[0]; + + if (!uri.fsPath.endsWith(".ql")) { throw new UserCancellationException("Not a CodeQL query file."); } @@ -314,15 +377,14 @@ export async function prepareRemoteQueryRun( throw new UserCancellationException("Cancelled"); } - const { remoteQueryDir, queryPackDir } = - await createRemoteQueriesTempDirectory(); + const tempDir = await createRemoteQueriesTempDirectory(); let pack: GeneratedQueryPack; try { - pack = await generateQueryPack(cliServer, queryFile, queryPackDir); + pack = await generateQueryPack(cliServer, queryFile, tempDir); } finally { - await remoteQueryDir.cleanup(); + await tempDir.remoteQueryDir.cleanup(); } const { base64Pack, language } = pack; @@ -389,11 +451,38 @@ async function fixPackFile( await writeFile(packPath, dump(qlpack)); } -async function injectExtensionPacks( +async function getExtensionPacksToInject( cliServer: CodeQLCliServer, - queryPackDir: string, workspaceFolders: string[], -) { +): Promise { + const result: string[] = []; + if (await cliServer.useExtensionPacks()) { + const extensionPacks = await cliServer.resolveQlpacks( + workspaceFolders, + true, + ); + Object.entries(extensionPacks).forEach(([name, paths]) => { + // We are guaranteed that there is at least one path found for each extension pack. + // If there are multiple paths, then we have a problem. This means that there is + // ambiguity in which path to use. This is an error. + if (paths.length > 1) { + throw new Error( + `Multiple versions of extension pack '${name}' found: ${paths.join( + ", ", + )}`, + ); + } + result.push(name); + }); + } + + return result; +} + +async function addExtensionPacksAsDependencies( + queryPackDir: string, + extensionPacks: string[], +): Promise { const qlpackFile = await getQlPackPath(queryPackDir); if (!qlpackFile) { throw new Error( @@ -402,24 +491,13 @@ async function injectExtensionPacks( )} file in '${queryPackDir}'`, ); } + const syntheticQueryPack = load( await readFile(qlpackFile, "utf8"), ) as QlPackFile; const dependencies = syntheticQueryPack.dependencies ?? {}; - - const extensionPacks = await cliServer.resolveQlpacks(workspaceFolders, true); - Object.entries(extensionPacks).forEach(([name, paths]) => { - // We are guaranteed that there is at least one path found for each extension pack. - // If there are multiple paths, then we have a problem. This means that there is - // ambiguity in which path to use. This is an error. - if (paths.length > 1) { - throw new Error( - `Multiple versions of extension pack '${name}' found: ${paths.join( - ", ", - )}`, - ); - } + extensionPacks.forEach((name) => { // Add this extension pack as a dependency. It doesn't matter which // version we specify, since we are guaranteed that the extension pack // is resolved from source at the given path. @@ -429,7 +507,6 @@ async function injectExtensionPacks( syntheticQueryPack.dependencies = dependencies; await writeFile(qlpackFile, dump(syntheticQueryPack)); - await cliServer.clearCache(); } function updateDefaultSuite(qlpack: QlPackFile, packRelativePath: string) { diff --git a/extensions/ql-vscode/src/variant-analysis/shared/variant-analysis-filter-sort.ts b/extensions/ql-vscode/src/variant-analysis/shared/variant-analysis-filter-sort.ts index 9d2d153bc..794439e13 100644 --- a/extensions/ql-vscode/src/variant-analysis/shared/variant-analysis-filter-sort.ts +++ b/extensions/ql-vscode/src/variant-analysis/shared/variant-analysis-filter-sort.ts @@ -146,8 +146,8 @@ export function filterAndSortRepositoriesWithResults< filterSortState.repositoryIds.length > 0 ) { return repositories - .filter( - (repo) => filterSortState.repositoryIds?.includes(repo.repository.id), + .filter((repo) => + filterSortState.repositoryIds?.includes(repo.repository.id), ) .sort(compareWithResults(filterSortState)); } diff --git a/extensions/ql-vscode/src/variant-analysis/shared/variant-analysis.ts b/extensions/ql-vscode/src/variant-analysis/shared/variant-analysis.ts index 31c4e0863..1c862b793 100644 --- a/extensions/ql-vscode/src/variant-analysis/shared/variant-analysis.ts +++ b/extensions/ql-vscode/src/variant-analysis/shared/variant-analysis.ts @@ -12,6 +12,7 @@ export interface VariantAnalysis { text: string; kind?: string; }; + queries?: VariantAnalysisQueries; databases: { repositories?: string[]; repositoryLists?: string[]; @@ -144,6 +145,7 @@ export interface VariantAnalysisSubmission { // Base64 encoded query pack. pack: string; }; + queries?: VariantAnalysisQueries; databases: { repositories?: string[]; repositoryLists?: string[]; @@ -151,6 +153,14 @@ export interface VariantAnalysisSubmission { }; } +// Experimental information about the queries that are +// going to be run as part of the variant analysis. +// For now, this is just the query language, but it's +// unclear what it will look like in the future. +export interface VariantAnalysisQueries { + language: QueryLanguage; +} + export async function isVariantAnalysisComplete( variantAnalysis: VariantAnalysis, artifactDownloaded: ( diff --git a/extensions/ql-vscode/src/variant-analysis/variant-analysis-manager.ts b/extensions/ql-vscode/src/variant-analysis/variant-analysis-manager.ts index c05a70e2b..b5452c59d 100644 --- a/extensions/ql-vscode/src/variant-analysis/variant-analysis-manager.ts +++ b/extensions/ql-vscode/src/variant-analysis/variant-analysis-manager.ts @@ -43,9 +43,9 @@ import type { } from "./variant-analysis-results-manager"; import { getQueryName, prepareRemoteQueryRun } from "./run-remote-query"; import { - processVariantAnalysis, - processVariantAnalysisRepositoryTask, -} from "./variant-analysis-processor"; + mapVariantAnalysis, + mapVariantAnalysisRepositoryTask, +} from "./variant-analysis-mapper"; import PQueue from "p-queue"; import { createTimestampFile, saveBeforeStart } from "../run-queries-shared"; import { readFile, remove, pathExists } from "fs-extra"; @@ -86,6 +86,8 @@ import { import type { QueryTreeViewItem } from "../queries-panel/query-tree-view-item"; import { RequestError } from "@octokit/request-error"; import { handleRequestError } from "./custom-errors"; +import { createMultiSelectionCommand } from "../common/vscode/selection-commands"; +import { askForLanguage } from "../codeql-cli/query-language"; const maxRetryCount = 3; @@ -166,12 +168,16 @@ export class VariantAnalysisManager "codeQL.openVariantAnalysisLogs": this.openVariantAnalysisLogs.bind(this), "codeQL.openVariantAnalysisView": this.showView.bind(this), "codeQL.runVariantAnalysis": - this.runVariantAnalysisFromCommand.bind(this), - // Since we are tracking extension usage through commands, this command mirrors the "codeQL.runVariantAnalysis" command + this.runVariantAnalysisFromCommandPalette.bind(this), "codeQL.runVariantAnalysisContextEditor": - this.runVariantAnalysisFromCommand.bind(this), + this.runVariantAnalysisFromContextEditor.bind(this), + "codeQL.runVariantAnalysisContextExplorer": createMultiSelectionCommand( + this.runVariantAnalysisFromExplorer.bind(this), + ), "codeQLQueries.runVariantAnalysisContextMenu": this.runVariantAnalysisFromQueriesPanel.bind(this), + "codeQL.runVariantAnalysisPublishedPack": + this.runVariantAnalysisFromPublishedPack.bind(this), }; } @@ -179,14 +185,120 @@ export class VariantAnalysisManager return this.app.commands; } - private async runVariantAnalysisFromCommand(uri?: Uri) { + private async runVariantAnalysisFromCommandPalette() { + const fileUri = Window.activeTextEditor?.document.uri; + if (!fileUri) { + throw new Error("Please select a .ql file to run as a variant analysis"); + } + + await this.runVariantAnalysisCommand(fileUri); + } + + private async runVariantAnalysisFromContextEditor(uri: Uri) { + await this.runVariantAnalysisCommand(uri); + } + + private async runVariantAnalysisFromExplorer(fileURIs: Uri[]): Promise { + if (fileURIs.length !== 1) { + throw new Error("Can only run a single query at a time"); + } + + return this.runVariantAnalysisCommand(fileURIs[0]); + } + + private async runVariantAnalysisFromQueriesPanel( + queryTreeViewItem: QueryTreeViewItem, + ): Promise { + if (queryTreeViewItem.path !== undefined) { + await this.runVariantAnalysisCommand(Uri.file(queryTreeViewItem.path)); + } + } + + public async runVariantAnalysisFromPublishedPack(): Promise { + return withProgress(async (progress, token) => { + progress({ + maxStep: 8, + step: 0, + message: "Determining query language", + }); + + const language = await askForLanguage(this.cliServer); + + progress({ + maxStep: 8, + step: 1, + message: "Downloading query pack", + }); + + const packName = `codeql/${language}-queries`; + const packDownloadResult = await this.cliServer.packDownload([packName]); + const downloadedPack = packDownloadResult.packs[0]; + + const packDir = join( + packDownloadResult.packDir, + downloadedPack.name, + downloadedPack.version, + ); + + progress({ + maxStep: 8, + step: 2, + message: "Resolving queries in pack", + }); + + const suitePath = join( + packDir, + "codeql-suites", + `${language}-code-scanning.qls`, + ); + const resolvedQueries = await this.cliServer.resolveQueries(suitePath); + + const problemQueries = + await this.filterToOnlyProblemQueries(resolvedQueries); + + if (problemQueries.length === 0) { + void this.app.logger.showErrorMessage( + `Unable to trigger variant analysis. No problem queries found in published query pack: ${packName}.`, + ); + return; + } + + await this.runVariantAnalysis( + problemQueries.map((q) => Uri.file(q)), + (p) => + progress({ + ...p, + maxStep: p.maxStep + 3, + step: p.step + 3, + }), + token, + ); + }); + } + + private async filterToOnlyProblemQueries( + queries: string[], + ): Promise { + const problemQueries: string[] = []; + for (const query of queries) { + const queryMetadata = await this.cliServer.resolveMetadata(query); + if ( + queryMetadata.kind === "problem" || + queryMetadata.kind === "path-problem" + ) { + problemQueries.push(query); + } else { + void this.app.logger.log(`Skipping non-problem query ${query}`); + } + } + return problemQueries; + } + + private async runVariantAnalysisCommand(uri: Uri): Promise { return withProgress( - async (progress, token) => - this.runVariantAnalysis( - uri || Window.activeTextEditor?.document.uri, - progress, - token, - ), + async (progress, token) => { + await this.runVariantAnalysis([uri], progress, token); + }, { title: "Run Variant Analysis", cancellable: true, @@ -194,18 +306,8 @@ export class VariantAnalysisManager ); } - private async runVariantAnalysisFromQueriesPanel( - queryTreeViewItem: QueryTreeViewItem, - ): Promise { - if (queryTreeViewItem.path !== undefined) { - await this.runVariantAnalysisFromCommand( - Uri.file(queryTreeViewItem.path), - ); - } - } - public async runVariantAnalysis( - uri: Uri | undefined, + uris: Uri[], progress: ProgressCallback, token: CancellationToken, ): Promise { @@ -229,7 +331,7 @@ export class VariantAnalysisManager } = await prepareRemoteQueryRun( this.cliServer, this.app.credentials, - uri, + uris, progress, token, this.dbManager, @@ -245,6 +347,13 @@ export class VariantAnalysisManager const queryText = await readFile(queryFile, "utf8"); + const queries = + uris.length === 1 + ? undefined + : { + language: variantAnalysisLanguage, + }; + const variantAnalysisSubmission: VariantAnalysisSubmission = { startTime: queryStartTime, actionRepoRef: actionBranch, @@ -257,6 +366,7 @@ export class VariantAnalysisManager text: queryText, kind: queryMetadata?.kind, }, + queries, databases: { repositories: repoSelection.repositories, repositoryLists: repoSelection.repositoryLists, @@ -279,7 +389,7 @@ export class VariantAnalysisManager throw e; } - const processedVariantAnalysis = processVariantAnalysis( + const processedVariantAnalysis = mapVariantAnalysis( variantAnalysisSubmission, variantAnalysisResponse, ); @@ -619,7 +729,7 @@ export class VariantAnalysisManager scannedRepo.repository.id, ); - repoTask = processVariantAnalysisRepositoryTask(repoTaskResponse); + repoTask = mapVariantAnalysisRepositoryTask(repoTaskResponse); } catch (e) { repoState.downloadStatus = VariantAnalysisScannedRepositoryDownloadStatus.Failed; diff --git a/extensions/ql-vscode/src/variant-analysis/variant-analysis-processor.ts b/extensions/ql-vscode/src/variant-analysis/variant-analysis-mapper.ts similarity index 82% rename from extensions/ql-vscode/src/variant-analysis/variant-analysis-processor.ts rename to extensions/ql-vscode/src/variant-analysis/variant-analysis-mapper.ts index f6b83136c..f6e1cb581 100644 --- a/extensions/ql-vscode/src/variant-analysis/variant-analysis-processor.ts +++ b/extensions/ql-vscode/src/variant-analysis/variant-analysis-mapper.ts @@ -23,11 +23,11 @@ import { VariantAnalysisRepoStatus, } from "./shared/variant-analysis"; -export function processVariantAnalysis( +export function mapVariantAnalysis( submission: VariantAnalysisSubmission, response: ApiVariantAnalysis, ): VariantAnalysis { - return processUpdatedVariantAnalysis( + return mapUpdatedVariantAnalysis( { query: { name: submission.query.name, @@ -36,6 +36,7 @@ export function processVariantAnalysis( text: submission.query.text, kind: submission.query.kind, }, + queries: submission.queries, databases: submission.databases, executionStartTime: submission.startTime, }, @@ -43,10 +44,10 @@ export function processVariantAnalysis( ); } -export function processUpdatedVariantAnalysis( +export function mapUpdatedVariantAnalysis( previousVariantAnalysis: Pick< VariantAnalysis, - "query" | "databases" | "executionStartTime" + "query" | "queries" | "databases" | "executionStartTime" >, response: ApiVariantAnalysis, ): VariantAnalysis { @@ -54,13 +55,13 @@ export function processUpdatedVariantAnalysis( let skippedRepos: VariantAnalysisSkippedRepositories = {}; if (response.scanned_repositories) { - scannedRepos = processScannedRepositories( + scannedRepos = mapScannedRepositories( response.scanned_repositories as ApiVariantAnalysisScannedRepository[], ); } if (response.skipped_repositories) { - skippedRepos = processSkippedRepositories( + skippedRepos = mapSkippedRepositories( response.skipped_repositories as ApiVariantAnalysisSkippedRepositories, ); } @@ -73,11 +74,12 @@ export function processUpdatedVariantAnalysis( private: response.controller_repo.private, }, query: previousVariantAnalysis.query, + queries: previousVariantAnalysis.queries, databases: previousVariantAnalysis.databases, executionStartTime: previousVariantAnalysis.executionStartTime, createdAt: response.created_at, updatedAt: response.updated_at, - status: processApiStatus(response.status), + status: mapApiStatus(response.status), completedAt: response.completed_at, actionsWorkflowRunId: response.actions_workflow_run_id, scannedRepos, @@ -85,15 +87,13 @@ export function processUpdatedVariantAnalysis( }; if (response.failure_reason) { - variantAnalysis.failureReason = processFailureReason( - response.failure_reason, - ); + variantAnalysis.failureReason = mapFailureReason(response.failure_reason); } return variantAnalysis; } -export function processVariantAnalysisRepositoryTask( +export function mapVariantAnalysisRepositoryTask( response: ApiVariantAnalysisRepoTask, ): VariantAnalysisRepositoryTask { return { @@ -102,7 +102,7 @@ export function processVariantAnalysisRepositoryTask( fullName: response.repository.full_name, private: response.repository.private, }, - analysisStatus: processApiRepoStatus(response.analysis_status), + analysisStatus: mapApiRepoStatus(response.analysis_status), resultCount: response.result_count, artifactSizeInBytes: response.artifact_size_in_bytes, failureMessage: response.failure_message, @@ -112,7 +112,7 @@ export function processVariantAnalysisRepositoryTask( }; } -export function processScannedRepository( +export function mapScannedRepository( scannedRepo: ApiVariantAnalysisScannedRepository, ): VariantAnalysisScannedRepository { return { @@ -123,33 +123,31 @@ export function processScannedRepository( stargazersCount: scannedRepo.repository.stargazers_count, updatedAt: scannedRepo.repository.updated_at, }, - analysisStatus: processApiRepoStatus(scannedRepo.analysis_status), + analysisStatus: mapApiRepoStatus(scannedRepo.analysis_status), resultCount: scannedRepo.result_count, artifactSizeInBytes: scannedRepo.artifact_size_in_bytes, failureMessage: scannedRepo.failure_message, }; } -function processScannedRepositories( +function mapScannedRepositories( scannedRepos: ApiVariantAnalysisScannedRepository[], ): VariantAnalysisScannedRepository[] { - return scannedRepos.map((scannedRepo) => - processScannedRepository(scannedRepo), - ); + return scannedRepos.map((scannedRepo) => mapScannedRepository(scannedRepo)); } -function processSkippedRepositories( +function mapSkippedRepositories( skippedRepos: ApiVariantAnalysisSkippedRepositories, ): VariantAnalysisSkippedRepositories { return { - accessMismatchRepos: processRepoGroup(skippedRepos.access_mismatch_repos), - notFoundRepos: processNotFoundRepoGroup(skippedRepos.not_found_repos), - noCodeqlDbRepos: processRepoGroup(skippedRepos.no_codeql_db_repos), - overLimitRepos: processRepoGroup(skippedRepos.over_limit_repos), + accessMismatchRepos: mapRepoGroup(skippedRepos.access_mismatch_repos), + notFoundRepos: mapNotFoundRepoGroup(skippedRepos.not_found_repos), + noCodeqlDbRepos: mapRepoGroup(skippedRepos.no_codeql_db_repos), + overLimitRepos: mapRepoGroup(skippedRepos.over_limit_repos), }; } -function processRepoGroup( +function mapRepoGroup( repoGroup: ApiVariantAnalysisSkippedRepositoryGroup | undefined, ): VariantAnalysisSkippedRepositoryGroup | undefined { if (!repoGroup) { @@ -172,7 +170,7 @@ function processRepoGroup( }; } -function processNotFoundRepoGroup( +function mapNotFoundRepoGroup( repoGroup: ApiVariantAnalysisNotFoundRepositoryGroup | undefined, ): VariantAnalysisSkippedRepositoryGroup | undefined { if (!repoGroup) { @@ -191,7 +189,7 @@ function processNotFoundRepoGroup( }; } -function processApiRepoStatus( +function mapApiRepoStatus( analysisStatus: ApiVariantAnalysisRepoStatus, ): VariantAnalysisRepoStatus { switch (analysisStatus) { @@ -210,9 +208,7 @@ function processApiRepoStatus( } } -function processApiStatus( - status: ApiVariantAnalysisStatus, -): VariantAnalysisStatus { +function mapApiStatus(status: ApiVariantAnalysisStatus): VariantAnalysisStatus { if (status === "succeeded") { return VariantAnalysisStatus.Succeeded; } else if (status === "in_progress") { @@ -226,7 +222,7 @@ function processApiStatus( } } -export function processFailureReason( +export function mapFailureReason( failureReason: ApiVariantAnalysisFailureReason, ): VariantAnalysisFailureReason { switch (failureReason) { diff --git a/extensions/ql-vscode/src/variant-analysis/variant-analysis-monitor.ts b/extensions/ql-vscode/src/variant-analysis/variant-analysis-monitor.ts index f2ce075b5..490cf883d 100644 --- a/extensions/ql-vscode/src/variant-analysis/variant-analysis-monitor.ts +++ b/extensions/ql-vscode/src/variant-analysis/variant-analysis-monitor.ts @@ -11,7 +11,7 @@ import { repoHasDownloadableArtifact, } from "./shared/variant-analysis"; import type { VariantAnalysis as ApiVariantAnalysis } from "./gh-api/variant-analysis"; -import { processUpdatedVariantAnalysis } from "./variant-analysis-processor"; +import { mapUpdatedVariantAnalysis } from "./variant-analysis-mapper"; import { DisposableObject } from "../common/disposable-object"; import { sleep } from "../common/time"; import { getErrorMessage } from "../common/helpers-pure"; @@ -119,7 +119,7 @@ export class VariantAnalysisMonitor extends DisposableObject { continue; } - variantAnalysis = processUpdatedVariantAnalysis( + variantAnalysis = mapUpdatedVariantAnalysis( variantAnalysis, variantAnalysisSummary, ); diff --git a/extensions/ql-vscode/src/view/common/DataGrid.tsx b/extensions/ql-vscode/src/view/common/DataGrid.tsx index ee790a6ae..a8bce90b0 100644 --- a/extensions/ql-vscode/src/view/common/DataGrid.tsx +++ b/extensions/ql-vscode/src/view/common/DataGrid.tsx @@ -37,7 +37,10 @@ export function DataGrid({ gridTemplateColumns, children }: DataGridProps) { ); } -const StyledDataGridRow = styled.div<{ $focused?: boolean }>` +const StyledDataGridRow = styled.div<{ + $focused?: boolean; + $selected?: boolean; +}>` display: contents; &:hover > * { @@ -48,14 +51,18 @@ const StyledDataGridRow = styled.div<{ $focused?: boolean }>` // Use !important to override the background color set by the hover state background-color: ${(props) => props.$focused - ? "var(--vscode-editor-selectionBackground) !important" - : "inherit"}; + ? "var(--vscode-editor-findMatchHighlightBackground) !important" + : props.$selected + ? "var(--vscode-editor-selectionBackground) !important" + : "inherit"}; } `; interface DataGridRowProps { focused?: boolean; + selected?: boolean; children: ReactNode; + onClick?: () => void; "data-testid"?: string; } @@ -69,10 +76,22 @@ interface DataGridRowProps { */ export const DataGridRow = forwardRef( ( - { focused, children, "data-testid": testId }: DataGridRowProps, + { + focused, + selected, + children, + "data-testid": testId, + onClick, + }: DataGridRowProps, ref?: React.Ref, ) => ( - + {children} ), diff --git a/extensions/ql-vscode/src/view/common/Dropdown.tsx b/extensions/ql-vscode/src/view/common/Dropdown.tsx index 17c4e9048..ffd7e3582 100644 --- a/extensions/ql-vscode/src/view/common/Dropdown.tsx +++ b/extensions/ql-vscode/src/view/common/Dropdown.tsx @@ -25,6 +25,9 @@ type Props = { "aria-label"?: string; }; +const stopClickPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); +}; /** * A dropdown implementation styled to look like `VSCodeDropdown`. * @@ -50,6 +53,7 @@ export function Dropdown({ value={disabled ? disabledValue : value} disabled={disabled} onChange={onChange} + onClick={stopClickPropagation} className={className} {...props} > diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/HighlightedText.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/HighlightedText.tsx new file mode 100644 index 000000000..6b1586eac --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/HighlightedText.tsx @@ -0,0 +1,26 @@ +import { styled } from "styled-components"; +import type { Snippet } from "./highlight"; + +const Normal = styled.span``; +const Highlighted = styled.span` + font-weight: 700; + color: var(--vscode-editorSuggestWidget-focusHighlightForeground); +`; + +type Props = { + snippets: Snippet[]; +}; + +export const HighlightedText = ({ snippets }: Props) => { + return ( + <> + {snippets.map((snippet, index) => + snippet.highlight ? ( + {snippet.text} + ) : ( + {snippet.text} + ), + )} + + ); +}; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/LabelText.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/LabelText.tsx new file mode 100644 index 000000000..d5c491759 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/LabelText.tsx @@ -0,0 +1,20 @@ +import { useMemo } from "react"; +import { createHighlights } from "./highlight"; +import { HighlightedText } from "./HighlightedText"; +import type { Option } from "./options"; + +type Props> = { + item: T; + + tokens: string[]; +}; + +export const LabelText = >({ item, tokens }: Props) => { + const highlights = useMemo(() => { + const highlightedToken = tokens[tokens.length - 1] ?? ""; + + return createHighlights(item.label, highlightedToken); + }, [item, tokens]); + + return ; +}; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx new file mode 100644 index 000000000..62dce6a8a --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx @@ -0,0 +1,275 @@ +import type { FormEvent, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + size, + useDismiss, + useFloating, + useFocus, + useInteractions, + useListNavigation, + useRole, +} from "@floating-ui/react"; +import { css, styled } from "styled-components"; +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"; +import type { Option } from "./options"; +import { findMatchingOptions } from "./options"; +import { SuggestBoxItem } from "./SuggestBoxItem"; +import { LabelText } from "./LabelText"; +import type { Diagnostic } from "./diagnostics"; + +const Input = styled(VSCodeTextField)<{ $error: boolean }>` + width: 430px; + + font-family: var(--vscode-editor-font-family); + + ${(props) => + props.$error && + css` + --dropdown-border: var(--vscode-inputValidation-errorBorder); + --focus-border: var(--vscode-inputValidation-errorBorder); + `} +`; + +const Container = styled.div` + width: 430px; + display: flex; + flex-direction: column; + border-radius: 3px; + + background-color: var(--vscode-editorSuggestWidget-background); + border: 1px solid var(--vscode-editorSuggestWidget-border); + + user-select: none; +`; + +const ListContainer = styled(Container)` + font-size: 95%; +`; + +const NoSuggestionsContainer = styled(Container)` + padding-top: 2px; + padding-bottom: 2px; +`; + +const NoSuggestionsText = styled.div` + padding-left: 22px; +`; + +export type SuggestBoxProps< + T extends Option, + D extends Diagnostic = Diagnostic, +> = { + value?: string; + onChange: (value: string) => void; + options: T[]; + + /** + * Parse the value into tokens that can be used to match against the options. The + * tokens will be passed to {@link findMatchingOptions}. + * @param value The user-entered value to parse. + */ + parseValueToTokens: (value: string) => string[]; + + /** + * Validate the value. This is used to show syntax errors in the input. + * @param value The user-entered value to validate. + */ + validateValue?: (value: string) => D[]; + + /** + * Get the icon to display for an option. + * @param option The option to get the icon for. + */ + getIcon?: (option: T) => ReactNode | undefined; + /** + * Get the details text to display for an option. + * @param option The option to get the details for. + */ + getDetails?: (option: T) => ReactNode | undefined; + + disabled?: boolean; + + "aria-label"?: string; + + /** + * Can be used to render a different component for the input. This is used + * in testing to use default HTML components rather than the VSCodeTextField + * for easier testing. + * @param props The props returned by `getReferenceProps` of {@link useInteractions} + */ + renderInputComponent?: ( + props: Record, + hasError: boolean, + ) => ReactNode; +}; + +export const SuggestBox = < + T extends Option, + D extends Diagnostic = Diagnostic, +>({ + value = "", + onChange, + options, + parseValueToTokens, + validateValue, + getIcon, + getDetails, + disabled, + "aria-label": ariaLabel, + renderInputComponent = (props, hasError) => ( + + ), +}: SuggestBoxProps) => { + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + + const listRef = useRef>([]); + + const { refs, floatingStyles, context } = useFloating({ + whileElementsMounted: autoUpdate, + open: isOpen, + onOpenChange: setIsOpen, + placement: "bottom-start", + middleware: [ + // Flip when the popover is too close to the bottom of the screen + flip({ padding: 10 }), + // Resize the popover to be fill the available height + size({ + apply({ availableHeight, elements }) { + Object.assign(elements.floating.style, { + maxHeight: `${availableHeight}px`, + }); + }, + padding: 10, + }), + ], + }); + + const focus = useFocus(context); + const role = useRole(context, { role: "listbox" }); + const dismiss = useDismiss(context); + const listNav = useListNavigation(context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + virtual: true, + loop: true, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( + [focus, role, dismiss, listNav], + ); + + const handleInput = useCallback( + (event: FormEvent) => { + const value = event.currentTarget.value; + onChange(value); + setIsOpen(true); + setActiveIndex(0); + }, + [onChange], + ); + + const tokens = useMemo(() => { + return parseValueToTokens(value); + }, [value, parseValueToTokens]); + + const suggestionItems = useMemo(() => { + return findMatchingOptions(options, tokens); + }, [options, tokens]); + + const diagnostics = useMemo( + () => validateValue?.(value) ?? [], + [validateValue, value], + ); + + const hasSyntaxError = diagnostics.length > 0; + + useEffect(() => { + if (disabled) { + setIsOpen(false); + } + }, [disabled]); + + return ( + <> + {renderInputComponent( + getReferenceProps({ + ref: refs.setReference, + value, + onInput: handleInput, + "aria-autocomplete": "list", + "aria-label": ariaLabel, + onKeyDown: (event) => { + // When the user presses the enter key, select the active item + if ( + event.key === "Enter" && + activeIndex !== null && + suggestionItems[activeIndex] + ) { + onChange(suggestionItems[activeIndex].value); + setActiveIndex(null); + setIsOpen(false); + } + }, + disabled, + }), + hasSyntaxError, + )} + {isOpen && ( + + {value && suggestionItems.length === 0 && ( + + No suggestions. + + )} + {suggestionItems.length > 0 && ( + + + {suggestionItems.map((item, index) => ( + } + details={getDetails?.(item)} + /> + ))} + + + )} + + )} + + ); +}; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBoxItem.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBoxItem.tsx new file mode 100644 index 000000000..1693d46f1 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBoxItem.tsx @@ -0,0 +1,86 @@ +import type { HTMLProps, ReactNode } from "react"; +import { forwardRef } from "react"; +import { useId } from "@floating-ui/react"; +import { styled } from "styled-components"; + +const Container = styled.div<{ $active: boolean }>` + display: flex; + box-sizing: border-box; + padding-right: 10px; + background-repeat: no-repeat; + background-position: 2px 2px; + white-space: nowrap; + cursor: pointer; + touch-action: none; + padding-left: 2px; + + font-family: var(--vscode-editor-font-family); + + color: ${(props) => + props.$active + ? "var(--vscode-editorSuggestWidget-selectedForeground)" + : "var(--vscode-editorSuggestWidget-foreground)"}; + background-color: ${(props) => + props.$active + ? "var(--vscode-editorSuggestWidget-selectedBackground)" + : "transparent"}; +`; + +const LabelContainer = styled.div` + flex: 1; + display: flex; + overflow: hidden; + white-space: pre; + justify-content: space-between; + align-items: center; +`; + +const Label = styled.span` + flex-shrink: 1; + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; +`; + +const DetailsLabel = styled.span` + overflow: hidden; + flex-shrink: 4; + max-width: 70%; + + font-size: 85%; + margin-left: 1.1em; + opacity: 0.7; + text-overflow: ellipsis; + white-space: nowrap; +`; + +type Props = { + active: boolean; + icon?: ReactNode; + labelText: ReactNode; + details?: ReactNode; +}; + +export const SuggestBoxItem = forwardRef< + HTMLDivElement, + Props & HTMLProps +>(({ children, active, icon, labelText, details, ...props }, ref) => { + const id = useId(); + return ( + + {icon} + + + {details && {details}} + + + ); +}); +SuggestBoxItem.displayName = "SuggestBoxItem"; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/SuggestBox.test.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/SuggestBox.test.tsx new file mode 100644 index 000000000..21225f898 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/SuggestBox.test.tsx @@ -0,0 +1,240 @@ +import { render as reactRender, screen } from "@testing-library/react"; +import type { SuggestBoxProps } from "../SuggestBox"; +import { SuggestBox } from "../SuggestBox"; +import { userEvent } from "@testing-library/user-event"; + +type TestOption = { + label: string; + value: string; + followup?: TestOption[]; +}; + +const options: TestOption[] = [ + { + label: "Argument[self]", + value: "Argument[self]", + }, + { + label: "Argument[0]", + value: "Argument[0]", + followup: [ + { + label: "Element[0]", + value: "Argument[0].Element[0]", + }, + { + label: "Element[1]", + value: "Argument[0].Element[1]", + }, + { + label: "Element[any]", + value: "Argument[0].Element[any]", + }, + ], + }, + { + label: "Argument[1]", + value: "Argument[1]", + }, + { + label: "Argument[text_rep:]", + value: "Argument[text_rep:]", + }, + { + label: "Argument[block]", + value: "Argument[block]", + followup: [ + { + label: "Parameter[0]", + value: "Argument[block].Parameter[0]", + followup: [ + { + label: "Element[:query]", + value: "Argument[block].Parameter[0].Element[:query]", + }, + { + label: "Element[:parameters]", + value: "Argument[block].Parameter[0].Element[:parameters]", + }, + ], + }, + { + label: "Parameter[1]", + value: "Argument[block].Parameter[1]", + followup: [ + { + label: "Field[@query]", + value: "Argument[block].Parameter[1].Field[@query]", + }, + ], + }, + ], + }, + { + label: "ReturnValue", + value: "ReturnValue", + }, +]; + +describe("SuggestBox", () => { + const onChange = jest.fn(); + const parseValueToTokens = jest.fn(); + const render = (props?: Partial>) => + reactRender( + } + {...props} + />, + ); + + beforeEach(() => { + onChange.mockReset(); + parseValueToTokens + .mockReset() + .mockImplementation((value: string) => value.split(".")); + }); + + it("does not render the options by default", () => { + render(); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("renders the options after clicking on the text field", async () => { + render(); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getAllByRole("option")).toHaveLength(options.length); + }); + + it("calls onChange after entering text", async () => { + render({ + value: "Argument[block]", + }); + + await userEvent.type(screen.getByRole("combobox"), "."); + + expect(onChange).toHaveBeenCalledWith("Argument[block]."); + }); + + it("calls onChange after clearing text", async () => { + render({ + value: "Argument[block].", + }); + + await userEvent.clear(screen.getByRole("combobox")); + + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("renders matching options with a single token", async () => { + render({ + value: "block", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getByRole("option")).toHaveTextContent("Argument[block]"); + }); + + it("renders followup options with a token and an empty token", async () => { + render({ + value: "Argument[block].", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getAllByRole("option")).toHaveLength(2); + }); + + it("renders matching followup options with two tokens", async () => { + render({ + value: "Argument[block].1", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getByRole("option")).toHaveTextContent("Parameter[1]"); + }); + + it("closes the options when selecting an option", async () => { + render({ + value: "Argument[block].1", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("{Enter}"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("shows no suggestions with no matching followup options", async () => { + render({ + value: "Argument[block].block", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + expect(screen.getByText("No suggestions.")).toBeInTheDocument(); + }); + + it("can navigate the options using the keyboard", async () => { + render({ + value: "", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard( + "{ArrowDown}{ArrowDown}{ArrowUp}{ArrowDown}{ArrowDown}{Enter}", + ); + + expect(onChange).toHaveBeenCalledWith("Argument[text_rep:]"); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("can use loop navigation when using the keyboard", async () => { + render({ + value: "", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("{ArrowUp}{ArrowUp}{Enter}"); + + expect(onChange).toHaveBeenCalledWith("Argument[block]"); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("can close the options using escape", async () => { + render({ + value: "", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getAllByRole("option")).toHaveLength(options.length); + + await userEvent.keyboard("{Escape}"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("opens the options when using backspace on a selected option", async () => { + render({ + value: "Argument[block].1", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("{Enter}"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + + await userEvent.keyboard("{Backspace}"); + + expect(screen.getAllByRole("option")).toHaveLength(1); + }); +}); diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/highlight.test.ts b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/highlight.test.ts new file mode 100644 index 000000000..283dea416 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/highlight.test.ts @@ -0,0 +1,72 @@ +import { createHighlights } from "../highlight"; + +describe("createHighlights", () => { + it.each([ + { + text: "Argument[foo].Element.Field[@test]", + search: "Argument[foo]", + snippets: [ + { text: "Argument[foo]", highlight: true }, + { + text: ".Element.Field[@test]", + highlight: false, + }, + ], + }, + { + text: "Field[@test]", + search: "test", + snippets: [ + { text: "Field[@", highlight: false }, + { + text: "test", + highlight: true, + }, + { + text: "]", + highlight: false, + }, + ], + }, + { + text: "Field[@test]", + search: "TEST", + snippets: [ + { text: "Field[@", highlight: false }, + { + text: "test", + highlight: true, + }, + { + text: "]", + highlight: false, + }, + ], + }, + { + text: "Field[@test]", + search: "[@TEST", + snippets: [ + { text: "Field", highlight: false }, + { + text: "[@test", + highlight: true, + }, + { + text: "]", + highlight: false, + }, + ], + }, + { + text: "Field[@test]", + search: "", + snippets: [{ text: "Field[@test]", highlight: false }], + }, + ])( + `creates highlights for $text with $search`, + ({ text, search, snippets }) => { + expect(createHighlights(text, search)).toEqual(snippets); + }, + ); +}); diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/options.test.ts b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/options.test.ts new file mode 100644 index 000000000..e04bd29c9 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/options.test.ts @@ -0,0 +1,138 @@ +import { findMatchingOptions } from "../options"; + +type TestOption = { + label: string; + value: string; + followup?: TestOption[]; +}; + +const suggestedOptions: TestOption[] = [ + { + label: "Argument[self]", + value: "Argument[self]", + }, + { + label: "Argument[0]", + value: "Argument[0]", + followup: [ + { + label: "Element[0]", + value: "Argument[0].Element[0]", + }, + { + label: "Element[1]", + value: "Argument[0].Element[1]", + }, + ], + }, + { + label: "Argument[1]", + value: "Argument[1]", + }, + { + label: "Argument[text_rep:]", + value: "Argument[text_rep:]", + }, + { + label: "Argument[block]", + value: "Argument[block]", + followup: [ + { + label: "Parameter[0]", + value: "Argument[block].Parameter[0]", + followup: [ + { + label: "Element[:query]", + value: "Argument[block].Parameter[0].Element[:query]", + }, + { + label: "Element[:parameters]", + value: "Argument[block].Parameter[0].Element[:parameters]", + }, + ], + }, + { + label: "Parameter[1]", + value: "Argument[block].Parameter[1]", + followup: [ + { + label: "Field[@query]", + value: "Argument[block].Parameter[1].Field[@query]", + }, + ], + }, + ], + }, + { + label: "ReturnValue", + value: "ReturnValue", + }, +]; + +describe("findMatchingOptions", () => { + it.each([ + { + // Argument[block]. + tokens: ["Argument[block]", ""], + options: ["Argument[block].Parameter[0]", "Argument[block].Parameter[1]"], + }, + { + // Argument[block].Parameter[0] + tokens: ["Argument[block]", "Parameter[0]"], + options: ["Argument[block].Parameter[0]"], + }, + { + // Argument[block].Parameter[0]. + tokens: ["Argument[block]", "Parameter[0]", ""], + options: [ + "Argument[block].Parameter[0].Element[:query]", + "Argument[block].Parameter[0].Element[:parameters]", + ], + }, + { + // "" + tokens: [""], + options: [ + "Argument[self]", + "Argument[0]", + "Argument[1]", + "Argument[text_rep:]", + "Argument[block]", + "ReturnValue", + ], + }, + { + // "" + tokens: [], + options: [ + "Argument[self]", + "Argument[0]", + "Argument[1]", + "Argument[text_rep:]", + "Argument[block]", + "ReturnValue", + ], + }, + { + // block + tokens: ["block"], + options: ["Argument[block]"], + }, + { + // l + tokens: ["l"], + options: ["Argument[self]", "Argument[block]", "ReturnValue"], + }, + { + // L + tokens: ["L"], + options: ["Argument[self]", "Argument[block]", "ReturnValue"], + }, + ])(`creates options for $value`, ({ tokens, options }) => { + expect( + findMatchingOptions(suggestedOptions, tokens).map( + (option) => option.value, + ), + ).toEqual(options); + }); +}); diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/diagnostics.ts b/extensions/ql-vscode/src/view/common/SuggestBox/diagnostics.ts new file mode 100644 index 000000000..96bd1d11a --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/diagnostics.ts @@ -0,0 +1,21 @@ +/** + * A range of characters in a value. The start position is inclusive, the end position is exclusive. + */ +type DiagnosticRange = { + /** + * Zero-based index of the first character of the token. + */ + start: number; + /** + * Zero-based index of the character after the last character of the token. + */ + end: number; +}; + +/** + * A diagnostic message. + */ +export type Diagnostic = { + range: DiagnosticRange; + message: string; +}; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/highlight.ts b/extensions/ql-vscode/src/view/common/SuggestBox/highlight.ts new file mode 100644 index 000000000..a1fa4ba09 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/highlight.ts @@ -0,0 +1,49 @@ +export type Snippet = { + text: string; + highlight: boolean; +}; + +/** + * Highlight creates a list of snippets that can be used to render a highlighted + * string. This highlight is case-insensitive. + * + * @param text The text in which to create highlights + * @param search The string that will be highlighted in the text. + * @returns A list of snippets that can be used to render a highlighted string. + */ +export function createHighlights(text: string, search: string): Snippet[] { + if (search === "") { + return [{ text, highlight: false }]; + } + + const searchLower = search.toLowerCase(); + const textLower = text.toLowerCase(); + + const highlights: Snippet[] = []; + + let index = 0; + for (;;) { + const searchIndex = textLower.indexOf(searchLower, index); + if (searchIndex === -1) { + break; + } + + highlights.push({ + text: text.substring(index, searchIndex), + highlight: false, + }); + highlights.push({ + text: text.substring(searchIndex, searchIndex + search.length), + highlight: true, + }); + + index = searchIndex + search.length; + } + + highlights.push({ + text: text.substring(index), + highlight: false, + }); + + return highlights.filter((highlight) => highlight.text !== ""); +} diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/index.ts b/extensions/ql-vscode/src/view/common/SuggestBox/index.ts new file mode 100644 index 000000000..d5914f4d3 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/index.ts @@ -0,0 +1 @@ +export * from "./SuggestBox"; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/options.ts b/extensions/ql-vscode/src/view/common/SuggestBox/options.ts new file mode 100644 index 000000000..dfaadbad4 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/options.ts @@ -0,0 +1,45 @@ +export type Option> = { + label: string; + value: string; + followup?: T[]; +}; + +function findNestedMatchingOptions>( + parts: string[], + options: T[], +): T[] { + const part = parts[0]; + const rest = parts.slice(1); + + if (!part) { + return options; + } + + const matchingOption = options.find((item) => item.label === part); + if (!matchingOption) { + return []; + } + + if (rest.length === 0) { + return matchingOption.followup ?? []; + } + + return findNestedMatchingOptions(rest, matchingOption.followup ?? []); +} + +export function findMatchingOptions>( + options: T[], + tokens: string[], +): T[] { + if (tokens.length === 0) { + return options; + } + const prefixTokens = tokens.slice(0, tokens.length - 1); + const lastToken = tokens[tokens.length - 1]; + + const matchingOptions = findNestedMatchingOptions(prefixTokens, options); + + return matchingOptions.filter((item) => + item.label.toLowerCase().includes(lastToken.toLowerCase()), + ); +} diff --git a/extensions/ql-vscode/src/view/common/useDebounceCallback.ts b/extensions/ql-vscode/src/view/common/useDebounceCallback.ts new file mode 100644 index 000000000..9bde5db86 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/useDebounceCallback.ts @@ -0,0 +1,27 @@ +import { useEffect, useRef } from "react"; + +/** + * Call the callback after the value has not changed for a certain amount of time. + * @param value + * @param callback + * @param delay + */ +export function useDebounceCallback( + value: T, + callback: (value: T) => void, + delay?: number, +) { + const callbackRef = useRef<(value: T) => void>(callback); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + const timer = setTimeout(() => callbackRef.current(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); +} diff --git a/extensions/ql-vscode/src/view/model-editor/LibraryRow.tsx b/extensions/ql-vscode/src/view/model-editor/LibraryRow.tsx index 5f5c95faa..38f27590c 100644 --- a/extensions/ql-vscode/src/view/model-editor/LibraryRow.tsx +++ b/extensions/ql-vscode/src/view/model-editor/LibraryRow.tsx @@ -71,11 +71,13 @@ export type LibraryRowProps = { methods: Method[]; modeledMethodsMap: Record; modifiedSignatures: Set; + selectedSignatures: Set; inProgressMethods: Set; viewState: ModelEditorViewState; hideModeledMethods: boolean; revealedMethodSignature: string | null; onChange: (methodSignature: string, modeledMethods: ModeledMethod[]) => void; + onMethodClick: (methodSignature: string) => void; onSaveModelClick: (methodSignatures: string[]) => void; onGenerateFromLlmClick: ( dependencyName: string, @@ -92,11 +94,13 @@ export const LibraryRow = ({ methods, modeledMethodsMap, modifiedSignatures, + selectedSignatures, inProgressMethods, viewState, hideModeledMethods, revealedMethodSignature, onChange, + onMethodClick, onSaveModelClick, onGenerateFromLlmClick, onStopGenerateFromLlmClick, @@ -228,16 +232,18 @@ export const LibraryRow = ({ methods={methods} modeledMethodsMap={modeledMethodsMap} modifiedSignatures={modifiedSignatures} + selectedSignatures={selectedSignatures} inProgressMethods={inProgressMethods} viewState={viewState} hideModeledMethods={hideModeledMethods} revealedMethodSignature={revealedMethodSignature} onChange={onChange} + onMethodClick={onMethodClick} /> - Save + {selectedSignatures.size === 0 ? "Save" : "Save selected"} diff --git a/extensions/ql-vscode/src/view/model-editor/MethodRow.tsx b/extensions/ql-vscode/src/view/model-editor/MethodRow.tsx index 7323cda5a..890041560 100644 --- a/extensions/ql-vscode/src/view/model-editor/MethodRow.tsx +++ b/extensions/ql-vscode/src/view/model-editor/MethodRow.tsx @@ -70,10 +70,12 @@ export type MethodRowProps = { methodCanBeModeled: boolean; modeledMethods: ModeledMethod[]; methodIsUnsaved: boolean; + methodIsSelected: boolean; modelingInProgress: boolean; viewState: ModelEditorViewState; revealedMethodSignature: string | null; onChange: (methodSignature: string, modeledMethods: ModeledMethod[]) => void; + onMethodClick: (methodSignature: string) => void; }; export const MethodRow = (props: MethodRowProps) => { @@ -103,9 +105,11 @@ const ModelableMethodRow = forwardRef( method, modeledMethods: modeledMethodsProp, methodIsUnsaved, + methodIsSelected, viewState, revealedMethodSignature, onChange, + onMethodClick, } = props; const [focusedIndex, setFocusedIndex] = useState(null); @@ -186,6 +190,10 @@ const ModelableMethodRow = forwardRef( { + onMethodClick(method.signature); + }} > ( {viewState.mode === Mode.Application && ( - + { + event.stopPropagation(); + jumpToMethod(); + }} + > {method.usages.length} )} - View + { + event.stopPropagation(); + jumpToMethod(); + }} + > + View + {props.modelingInProgress && } @@ -269,7 +289,10 @@ const ModelableMethodRow = forwardRef( { + event.stopPropagation(); + handleAddModelClick(); + }} disabled={addModelButtonDisabled} > @@ -278,7 +301,10 @@ const ModelableMethodRow = forwardRef( { + event.stopPropagation(); + removeModelClickedHandlers[index](); + }} > diff --git a/extensions/ql-vscode/src/view/model-editor/ModelEditor.tsx b/extensions/ql-vscode/src/view/model-editor/ModelEditor.tsx index 2cfdd8bb7..b59f5e9cc 100644 --- a/extensions/ql-vscode/src/view/model-editor/ModelEditor.tsx +++ b/extensions/ql-vscode/src/view/model-editor/ModelEditor.tsx @@ -95,6 +95,10 @@ export function ModelEditor({ new Set(), ); + const [selectedSignatures, setSelectedSignatures] = useState>( + new Set(), + ); + const [inProgressMethods, setInProgressMethods] = useState>( new Set(), ); @@ -189,6 +193,19 @@ export function ModelEditor({ [], ); + const onMethodClick = useCallback( + (methodSignature: string) => { + const newSelectedSignatures = new Set(selectedSignatures); + if (selectedSignatures.has(methodSignature)) { + newSelectedSignatures.delete(methodSignature); + } else { + newSelectedSignatures.add(methodSignature); + } + setSelectedSignatures(newSelectedSignatures); + }, + [selectedSignatures], + ); + const onRefreshClick = useCallback(() => { vscode.postMessage({ t: "refreshMethods", @@ -198,15 +215,31 @@ export function ModelEditor({ const onSaveAllClick = useCallback(() => { vscode.postMessage({ t: "saveModeledMethods", + methodSignatures: + selectedSignatures.size === 0 + ? undefined + : Array.from(selectedSignatures), }); + }, [selectedSignatures]); + + const onDeselectAllClick = useCallback(() => { + setSelectedSignatures(new Set()); }, []); - const onSaveModelClick = useCallback((methodSignatures: string[]) => { - vscode.postMessage({ - t: "saveModeledMethods", - methodSignatures, - }); - }, []); + const onSaveModelClick = useCallback( + (methodSignatures: string[]) => { + vscode.postMessage({ + t: "saveModeledMethods", + methodSignatures: + selectedSignatures.size === 0 + ? methodSignatures + : methodSignatures.filter((signature) => + selectedSignatures.has(signature), + ), + }); + }, + [selectedSignatures], + ); const onGenerateFromSourceClick = useCallback(() => { vscode.postMessage({ @@ -309,7 +342,14 @@ export function ModelEditor({ onClick={onSaveAllClick} disabled={modifiedSignatures.size === 0} > - Save all + {selectedSignatures.size === 0 ? "Save all" : "Save selected"} + + + Deselect all Refresh @@ -339,11 +379,13 @@ export function ModelEditor({ methods={methods} modeledMethodsMap={modeledMethods} modifiedSignatures={modifiedSignatures} + selectedSignatures={selectedSignatures} inProgressMethods={inProgressMethods} viewState={viewState} hideModeledMethods={hideModeledMethods} revealedMethodSignature={revealedMethodSignature} onChange={onChange} + onMethodClick={onMethodClick} onSaveModelClick={onSaveModelClick} onGenerateFromLlmClick={onGenerateFromLlmClick} onStopGenerateFromLlmClick={onStopGenerateFromLlmClick} diff --git a/extensions/ql-vscode/src/view/model-editor/ModelInputDropdown.tsx b/extensions/ql-vscode/src/view/model-editor/ModelInputDropdown.tsx index 634769627..e50b5f566 100644 --- a/extensions/ql-vscode/src/view/model-editor/ModelInputDropdown.tsx +++ b/extensions/ql-vscode/src/view/model-editor/ModelInputDropdown.tsx @@ -7,11 +7,11 @@ import { modeledMethodSupportsInput, } from "../../model-editor/modeled-method"; import type { Method } from "../../model-editor/method"; -import { ReadonlyDropdown } from "../common/ReadonlyDropdown"; import type { QueryLanguage } from "../../common/query-language"; import { getModelsAsDataLanguage } from "../../model-editor/languages"; import type { ModelingStatus } from "../../model-editor/shared/modeling-status"; import { InputDropdown } from "./InputDropdown"; +import { ModelTypeTextbox } from "./ModelTypeTextbox"; type Props = { language: QueryLanguage; @@ -67,7 +67,14 @@ export const ModelInputDropdown = ({ : undefined; if (modeledMethod?.type === "type") { - return ; + return ( + + ); } const modelAccepted = isModelAccepted(modeledMethod, modelingStatus); diff --git a/extensions/ql-vscode/src/view/model-editor/ModelOutputDropdown.tsx b/extensions/ql-vscode/src/view/model-editor/ModelOutputDropdown.tsx index 39332bf93..5c5053903 100644 --- a/extensions/ql-vscode/src/view/model-editor/ModelOutputDropdown.tsx +++ b/extensions/ql-vscode/src/view/model-editor/ModelOutputDropdown.tsx @@ -7,11 +7,11 @@ import { modeledMethodSupportsOutput, } from "../../model-editor/modeled-method"; import type { Method } from "../../model-editor/method"; -import { ReadonlyDropdown } from "../common/ReadonlyDropdown"; import { getModelsAsDataLanguage } from "../../model-editor/languages"; import type { QueryLanguage } from "../../common/query-language"; import type { ModelingStatus } from "../../model-editor/shared/modeling-status"; import { InputDropdown } from "./InputDropdown"; +import { ModelTypeTextbox } from "./ModelTypeTextbox"; type Props = { language: QueryLanguage; @@ -69,8 +69,10 @@ export const ModelOutputDropdown = ({ if (modeledMethod?.type === "type") { return ( - ); diff --git a/extensions/ql-vscode/src/view/model-editor/ModelTypeDropdown.tsx b/extensions/ql-vscode/src/view/model-editor/ModelTypeDropdown.tsx index c2dfa35d9..98b9bf7c1 100644 --- a/extensions/ql-vscode/src/view/model-editor/ModelTypeDropdown.tsx +++ b/extensions/ql-vscode/src/view/model-editor/ModelTypeDropdown.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent } from "react"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import type { ModeledMethod, ModeledMethodType, @@ -12,19 +12,11 @@ import type { Method } from "../../model-editor/method"; import { createEmptyModeledMethod } from "../../model-editor/modeled-method-empty"; import type { Mutable } from "../../common/mutable"; import { ReadonlyDropdown } from "../common/ReadonlyDropdown"; -import type { QueryLanguage } from "../../common/query-language"; +import { QueryLanguage } from "../../common/query-language"; import { getModelsAsDataLanguage } from "../../model-editor/languages"; import type { ModelingStatus } from "../../model-editor/shared/modeling-status"; import { InputDropdown } from "./InputDropdown"; -const options: Array<{ value: ModeledMethodType; label: string }> = [ - { value: "none", label: "Unmodeled" }, - { value: "source", label: "Source" }, - { value: "sink", label: "Sink" }, - { value: "summary", label: "Flow summary" }, - { value: "neutral", label: "Neutral" }, -]; - type Props = { language: QueryLanguage; method: Method; @@ -40,6 +32,21 @@ export const ModelTypeDropdown = ({ modelingStatus, onChange, }: Props): JSX.Element => { + const options = useMemo(() => { + const baseOptions: Array<{ value: ModeledMethodType; label: string }> = [ + { value: "none", label: "Unmodeled" }, + { value: "source", label: "Source" }, + { value: "sink", label: "Sink" }, + { value: "summary", label: "Flow summary" }, + { value: "neutral", label: "Neutral" }, + ]; + if (language === QueryLanguage.Ruby) { + baseOptions.push({ value: "type", label: "Type" }); + } + + return baseOptions; + }, [language]); + const handleChange = useCallback( (e: ChangeEvent) => { const modelsAsDataLanguage = getModelsAsDataLanguage(language); diff --git a/extensions/ql-vscode/src/view/model-editor/ModelTypeTextbox.tsx b/extensions/ql-vscode/src/view/model-editor/ModelTypeTextbox.tsx new file mode 100644 index 000000000..48a2de7df --- /dev/null +++ b/extensions/ql-vscode/src/view/model-editor/ModelTypeTextbox.tsx @@ -0,0 +1,52 @@ +import type { ChangeEvent } from "react"; +import { useCallback, useEffect, useState } from "react"; +import type { + ModeledMethod, + TypeModeledMethod, +} from "../../model-editor/modeled-method"; +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"; +import { useDebounceCallback } from "../common/useDebounceCallback"; + +type Props = { + modeledMethod: TypeModeledMethod; + typeInfo: "path" | "relatedTypeName"; + onChange: (modeledMethod: ModeledMethod) => void; + + "aria-label"?: string; +}; + +export const ModelTypeTextbox = ({ + modeledMethod, + typeInfo, + onChange, + ...props +}: Props): JSX.Element => { + const [value, setValue] = useState( + modeledMethod[typeInfo], + ); + + useEffect(() => { + setValue(modeledMethod[typeInfo]); + }, [modeledMethod, typeInfo]); + + const handleChange = useCallback((e: ChangeEvent) => { + const target = e.target as HTMLSelectElement; + + setValue(target.value); + }, []); + + // Debounce the callback to avoid updating the model too often. + // Not doing this results in a lot of lag when typing. + useDebounceCallback( + value, + (newValue: string | undefined) => { + onChange({ + ...modeledMethod, + [typeInfo]: newValue ?? "", + }); + }, + 500, + ); + + return ; +}; diff --git a/extensions/ql-vscode/src/view/model-editor/ModeledMethodDataGrid.tsx b/extensions/ql-vscode/src/view/model-editor/ModeledMethodDataGrid.tsx index c50b29127..b3e521a4b 100644 --- a/extensions/ql-vscode/src/view/model-editor/ModeledMethodDataGrid.tsx +++ b/extensions/ql-vscode/src/view/model-editor/ModeledMethodDataGrid.tsx @@ -16,22 +16,26 @@ export type ModeledMethodDataGridProps = { methods: Method[]; modeledMethodsMap: Record; modifiedSignatures: Set; + selectedSignatures: Set; inProgressMethods: Set; viewState: ModelEditorViewState; hideModeledMethods: boolean; revealedMethodSignature: string | null; onChange: (methodSignature: string, modeledMethods: ModeledMethod[]) => void; + onMethodClick: (methodSignature: string) => void; }; export const ModeledMethodDataGrid = ({ methods, modeledMethodsMap, modifiedSignatures, + selectedSignatures, inProgressMethods, viewState, hideModeledMethods, revealedMethodSignature, onChange, + onMethodClick, }: ModeledMethodDataGridProps) => { const [methodsWithModelability, numHiddenMethods]: [ Array<{ method: Method; methodCanBeModeled: boolean }>, @@ -80,10 +84,12 @@ export const ModeledMethodDataGrid = ({ methodCanBeModeled={methodCanBeModeled} modeledMethods={modeledMethods} methodIsUnsaved={modifiedSignatures.has(method.signature)} + methodIsSelected={selectedSignatures.has(method.signature)} modelingInProgress={inProgressMethods.has(method.signature)} viewState={viewState} revealedMethodSignature={revealedMethodSignature} onChange={onChange} + onMethodClick={onMethodClick} /> ); })} diff --git a/extensions/ql-vscode/src/view/model-editor/ModeledMethodsList.tsx b/extensions/ql-vscode/src/view/model-editor/ModeledMethodsList.tsx index 40f545121..2a1388fc7 100644 --- a/extensions/ql-vscode/src/view/model-editor/ModeledMethodsList.tsx +++ b/extensions/ql-vscode/src/view/model-editor/ModeledMethodsList.tsx @@ -13,11 +13,13 @@ export type ModeledMethodsListProps = { methods: Method[]; modeledMethodsMap: Record; modifiedSignatures: Set; + selectedSignatures: Set; inProgressMethods: Set; revealedMethodSignature: string | null; viewState: ModelEditorViewState; hideModeledMethods: boolean; onChange: (methodSignature: string, modeledMethods: ModeledMethod[]) => void; + onMethodClick: (methodSignature: string) => void; onSaveModelClick: (methodSignatures: string[]) => void; onGenerateFromLlmClick: ( packageName: string, @@ -36,11 +38,13 @@ export const ModeledMethodsList = ({ methods, modeledMethodsMap, modifiedSignatures, + selectedSignatures, inProgressMethods, viewState, hideModeledMethods, revealedMethodSignature, onChange, + onMethodClick, onSaveModelClick, onGenerateFromLlmClick, onStopGenerateFromLlmClick, @@ -82,11 +86,13 @@ export const ModeledMethodsList = ({ methods={grouped[libraryName]} modeledMethodsMap={modeledMethodsMap} modifiedSignatures={modifiedSignatures} + selectedSignatures={selectedSignatures} inProgressMethods={inProgressMethods} viewState={viewState} hideModeledMethods={hideModeledMethods} revealedMethodSignature={revealedMethodSignature} onChange={onChange} + onMethodClick={onMethodClick} onSaveModelClick={onSaveModelClick} onGenerateFromLlmClick={onGenerateFromLlmClick} onStopGenerateFromLlmClick={onStopGenerateFromLlmClick} diff --git a/extensions/ql-vscode/src/view/model-editor/__tests__/LibraryRow.spec.tsx b/extensions/ql-vscode/src/view/model-editor/__tests__/LibraryRow.spec.tsx index 1f16cd1b9..4b0bf01e3 100644 --- a/extensions/ql-vscode/src/view/model-editor/__tests__/LibraryRow.spec.tsx +++ b/extensions/ql-vscode/src/view/model-editor/__tests__/LibraryRow.spec.tsx @@ -8,6 +8,7 @@ import { createMockModelEditorViewState } from "../../../../test/factories/model describe(LibraryRow.name, () => { const method = createMethod(); const onChange = jest.fn(); + const onMethodClick = jest.fn(); const onSaveModelClick = jest.fn(); const onGenerateFromLlmClick = jest.fn(); const onStopGenerateFromLlmClick = jest.fn(); @@ -33,11 +34,13 @@ describe(LibraryRow.name, () => { ], }} modifiedSignatures={new Set([method.signature])} + selectedSignatures={new Set()} inProgressMethods={new Set()} viewState={viewState} hideModeledMethods={false} revealedMethodSignature={null} onChange={onChange} + onMethodClick={onMethodClick} onSaveModelClick={onSaveModelClick} onGenerateFromLlmClick={onGenerateFromLlmClick} onStopGenerateFromLlmClick={onStopGenerateFromLlmClick} diff --git a/extensions/ql-vscode/src/view/model-editor/__tests__/MethodRow.spec.tsx b/extensions/ql-vscode/src/view/model-editor/__tests__/MethodRow.spec.tsx index fd2cd07df..6782cade7 100644 --- a/extensions/ql-vscode/src/view/model-editor/__tests__/MethodRow.spec.tsx +++ b/extensions/ql-vscode/src/view/model-editor/__tests__/MethodRow.spec.tsx @@ -30,6 +30,7 @@ describe(MethodRow.name, () => { provenance: "manual", }; const onChange = jest.fn(); + const onMethodClick = jest.fn(); const viewState = createMockModelEditorViewState(); @@ -40,10 +41,12 @@ describe(MethodRow.name, () => { methodCanBeModeled={true} modeledMethods={[modeledMethod]} methodIsUnsaved={false} + methodIsSelected={false} modelingInProgress={false} revealedMethodSignature={null} viewState={viewState} onChange={onChange} + onMethodClick={onMethodClick} {...props} />, ); diff --git a/extensions/ql-vscode/src/view/model-editor/__tests__/ModelEditor.spec.tsx b/extensions/ql-vscode/src/view/model-editor/__tests__/ModelEditor.spec.tsx new file mode 100644 index 000000000..42d3bba43 --- /dev/null +++ b/extensions/ql-vscode/src/view/model-editor/__tests__/ModelEditor.spec.tsx @@ -0,0 +1,69 @@ +import { act, render as reactRender, screen } from "@testing-library/react"; +import { createMethod } from "../../../../test/factories/model-editor/method-factories"; +import { ModelEditor } from "../ModelEditor"; +import { createMockModelEditorViewState } from "../../../../test/factories/model-editor/view-state"; +import { userEvent } from "@testing-library/user-event"; + +describe(ModelEditor.name, () => { + const method1 = createMethod({ + library: "sql2o", + libraryVersion: "1.6.0", + signature: "org.sql2o.Connection#createQuery(String)", + packageName: "org.sql2o", + typeName: "Connection", + methodName: "createQuery", + methodParameters: "(String)", + supported: false, + }); + const method2 = createMethod({ + library: "sql2o", + libraryVersion: "1.6.0", + signature: "org.sql2o.Query#executeScalar(Class)", + packageName: "org.sql2o", + typeName: "Query", + methodName: "executeScalar", + methodParameters: "(Class)", + supported: false, + }); + const method3 = createMethod({ + library: "sql2o", + libraryVersion: "1.6.0", + signature: "org.sql2o.Sql2o#open()", + packageName: "org.sql2o", + typeName: "Sql2o", + methodName: "open", + methodParameters: "()", + supported: true, + }); + + const viewState = createMockModelEditorViewState(); + + const render = () => + reactRender( + , + ); + + it("renders Save button when no rows are selected", () => { + render(); + + expect(screen.getByText("Save all")).toBeInTheDocument(); + }); + + it("renders Save button when rows are selected", async () => { + render(); + + await act(async () => { + await userEvent.click(screen.getAllByLabelText("Expand")[0]); + }); + + await act(async () => { + await userEvent.click(screen.getAllByTestId("modelable-method-row")[0]); + }); + + // The top-level Save button and the per-library Save button should have been updated. + expect(screen.getAllByText("Save selected")).toHaveLength(2); + }); +}); diff --git a/extensions/ql-vscode/src/view/model-editor/__tests__/ModelTypeDropdown.spec.tsx b/extensions/ql-vscode/src/view/model-editor/__tests__/ModelTypeDropdown.spec.tsx new file mode 100644 index 000000000..786907b62 --- /dev/null +++ b/extensions/ql-vscode/src/view/model-editor/__tests__/ModelTypeDropdown.spec.tsx @@ -0,0 +1,77 @@ +import { userEvent } from "@testing-library/user-event"; +import { render, screen } from "@testing-library/react"; +import { createNoneModeledMethod } from "../../../../test/factories/model-editor/modeled-method-factories"; +import { QueryLanguage } from "../../../common/query-language"; +import { ModelTypeDropdown } from "../ModelTypeDropdown"; +import { createMethod } from "../../../../test/factories/model-editor/method-factories"; + +describe(ModelTypeDropdown.name, () => { + const onChange = jest.fn(); + + beforeEach(() => { + onChange.mockReset(); + }); + + it("allows changing the type", async () => { + const method = createMethod(); + const modeledMethod = createNoneModeledMethod(); + + render( + , + ); + + await userEvent.selectOptions(screen.getByRole("combobox"), "source"); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + type: "source", + }), + ); + }); + + it("allows changing the type to 'Type' for Ruby", async () => { + const method = createMethod(); + const modeledMethod = createNoneModeledMethod(); + + render( + , + ); + + await userEvent.selectOptions(screen.getByRole("combobox"), "type"); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + type: "type", + }), + ); + }); + + it("does not allow changing the type to 'Type' for Java", async () => { + const method = createMethod(); + const modeledMethod = createNoneModeledMethod(); + + render( + , + ); + + expect( + screen.queryByRole("option", { name: "Type" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodDataGrid.spec.tsx b/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodDataGrid.spec.tsx index 44b44679d..c4176bf26 100644 --- a/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodDataGrid.spec.tsx +++ b/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodDataGrid.spec.tsx @@ -36,6 +36,7 @@ describe(ModeledMethodDataGrid.name, () => { supported: true, }); const onChange = jest.fn(); + const onMethodClick = jest.fn(); const viewState = createMockModelEditorViewState(); @@ -55,11 +56,13 @@ describe(ModeledMethodDataGrid.name, () => { ], }} modifiedSignatures={new Set([method1.signature])} + selectedSignatures={new Set()} inProgressMethods={new Set()} viewState={viewState} hideModeledMethods={false} revealedMethodSignature={null} onChange={onChange} + onMethodClick={onMethodClick} {...props} />, ); diff --git a/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodsList.spec.tsx b/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodsList.spec.tsx index 92296b52c..6717f2faf 100644 --- a/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodsList.spec.tsx +++ b/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodsList.spec.tsx @@ -33,6 +33,7 @@ describe(ModeledMethodsList.name, () => { methodParameters: "(String)", }); const onChange = jest.fn(); + const onMethodClick = jest.fn(); const onSaveModelClick = jest.fn(); const onGenerateFromLlmClick = jest.fn(); const onStopGenerateFromLlmClick = jest.fn(); @@ -56,11 +57,13 @@ describe(ModeledMethodsList.name, () => { ], }} modifiedSignatures={new Set([method1.signature])} + selectedSignatures={new Set()} inProgressMethods={new Set()} viewState={viewState} hideModeledMethods={false} revealedMethodSignature={null} onChange={onChange} + onMethodClick={onMethodClick} onSaveModelClick={onSaveModelClick} onGenerateFromLlmClick={onGenerateFromLlmClick} onStopGenerateFromLlmClick={onStopGenerateFromLlmClick} diff --git a/extensions/ql-vscode/supported_cli_versions.json b/extensions/ql-vscode/supported_cli_versions.json index 8d0961723..d29e63e4d 100644 --- a/extensions/ql-vscode/supported_cli_versions.json +++ b/extensions/ql-vscode/supported_cli_versions.json @@ -1,4 +1,5 @@ [ + "v2.16.0", "v2.15.5", "v2.14.6", "v2.13.5", diff --git a/extensions/ql-vscode/test/common/logging/output-channel-logger.test.ts b/extensions/ql-vscode/test/common/logging/output-channel-logger.test.ts index 10e6843d9..337960832 100644 --- a/extensions/ql-vscode/test/common/logging/output-channel-logger.test.ts +++ b/extensions/ql-vscode/test/common/logging/output-channel-logger.test.ts @@ -54,12 +54,12 @@ describe("OutputChannelLogger tests", function () { it("should log to the output channel", async () => { await logger.log("xxx"); - expect(mockOutputChannel.appendLine).toBeCalledWith("xxx"); - expect(mockOutputChannel.append).not.toBeCalledWith("xxx"); + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith("xxx"); + expect(mockOutputChannel.append).not.toHaveBeenCalledWith("xxx"); await logger.log("yyy", { trailingNewline: false }); - expect(mockOutputChannel.appendLine).not.toBeCalledWith("yyy"); - expect(mockOutputChannel.append).toBeCalledWith("yyy"); + expect(mockOutputChannel.appendLine).not.toHaveBeenCalledWith("yyy"); + expect(mockOutputChannel.append).toHaveBeenCalledWith("yyy"); const hucairz = createSideLogger(logger, "hucairz"); await hucairz.log("zzz"); diff --git a/extensions/ql-vscode/test/matchers/toEqualPath.ts b/extensions/ql-vscode/test/matchers/toEqualPath.ts index de82ddbe4..da048a84c 100644 --- a/extensions/ql-vscode/test/matchers/toEqualPath.ts +++ b/extensions/ql-vscode/test/matchers/toEqualPath.ts @@ -2,6 +2,7 @@ import { expect } from "@jest/globals"; import type { MatcherFunction } from "expect"; import { pathsEqual } from "../../src/common/files"; +// eslint-disable-next-line func-style -- We need to set the type of this function const toEqualPath: MatcherFunction<[expectedPath: unknown]> = function ( actual, expectedPath, diff --git a/extensions/ql-vscode/test/matchers/toExistInCodeQLPack.ts b/extensions/ql-vscode/test/matchers/toExistInCodeQLPack.ts new file mode 100644 index 000000000..622c41200 --- /dev/null +++ b/extensions/ql-vscode/test/matchers/toExistInCodeQLPack.ts @@ -0,0 +1,50 @@ +import { expect } from "@jest/globals"; +import type { MatcherFunction } from "expect"; +import type { QueryPackFS } from "../vscode-tests/utils/bundled-pack-helpers"; +import { EOL } from "os"; + +/** + * Custom Jest matcher to check if a file exists in a query pack. + */ +// eslint-disable-next-line func-style -- We need to set the type of this function +const toExistInCodeQLPack: MatcherFunction<[packFS: QueryPackFS]> = function ( + actual, + packFS, +) { + if (typeof actual !== "string") { + throw new TypeError( + `Expected actual value to be a string. Found ${typeof actual}`, + ); + } + + const pass = packFS.fileExists(actual); + if (pass) { + return { + pass: true, + message: () => `expected ${actual} not to exist in pack`, + }; + } else { + const files = packFS.allFiles(); + const filesString = files.length > 0 ? files.join(EOL) : ""; + return { + pass: false, + message: () => + `expected ${actual} to exist in pack.\nThe following files were found in the pack:\n${filesString}`, + }; + } +}; + +expect.extend({ toExistInCodeQLPack }); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace -- We need to extend this global declaration + namespace jest { + interface AsymmetricMatchers { + toExistInCodeQLPack(packFS: QueryPackFS): void; + } + + interface Matchers { + toExistInCodeQLPack(packFS: QueryPackFS): R; + } + } +} diff --git a/extensions/ql-vscode/test/unit-tests/common/disposable-object.test.ts b/extensions/ql-vscode/test/unit-tests/common/disposable-object.test.ts index 9b1348a49..b211141e3 100644 --- a/extensions/ql-vscode/test/unit-tests/common/disposable-object.test.ts +++ b/extensions/ql-vscode/test/unit-tests/common/disposable-object.test.ts @@ -34,9 +34,9 @@ describe("DisposableObject and DisposeHandler", () => { disposableObject.dispose(); - expect(disposable1.dispose).toBeCalled(); - expect(disposable2.dispose).toBeCalled(); - expect(disposable3.dispose).toBeCalled(); + expect(disposable1.dispose).toHaveBeenCalled(); + expect(disposable2.dispose).toHaveBeenCalled(); + expect(disposable3.dispose).toHaveBeenCalled(); // pushed items must be called in reverse order expect(disposable2.dispose.mock.invocationCallOrder[0]).toBeLessThan( @@ -51,30 +51,30 @@ describe("DisposableObject and DisposeHandler", () => { disposableObject.dispose(); - expect(disposable1.dispose).not.toBeCalled(); - expect(disposable2.dispose).not.toBeCalled(); - expect(disposable3.dispose).not.toBeCalled(); + expect(disposable1.dispose).not.toHaveBeenCalled(); + expect(disposable2.dispose).not.toHaveBeenCalled(); + expect(disposable3.dispose).not.toHaveBeenCalled(); }); it("should dispose and stop tracking objects", () => { disposableObject.track(disposable1); disposableObject.disposeAndStopTracking(disposable1); - expect(disposable1.dispose).toBeCalled(); + expect(disposable1.dispose).toHaveBeenCalled(); disposable1.dispose.mockClear(); disposableObject.dispose(); - expect(disposable1.dispose).not.toBeCalled(); + expect(disposable1.dispose).not.toHaveBeenCalled(); }); it("should avoid disposing an object that is not tracked", () => { disposableObject.push(disposable1); disposableObject.disposeAndStopTracking(disposable1); - expect(disposable1.dispose).not.toBeCalled(); + expect(disposable1.dispose).not.toHaveBeenCalled(); disposableObject.dispose(); - expect(disposable1.dispose).toBeCalled(); + expect(disposable1.dispose).toHaveBeenCalled(); }); it("ahould use a dispose handler", () => { @@ -91,10 +91,10 @@ describe("DisposableObject and DisposeHandler", () => { disposableObject.dispose(handler); - expect(disposable1.dispose).toBeCalled(); - expect(disposable2.dispose).not.toBeCalled(); - expect(disposable3.dispose).toBeCalled(); - expect(disposable4.dispose).not.toBeCalled(); + expect(disposable1.dispose).toHaveBeenCalled(); + expect(disposable2.dispose).not.toHaveBeenCalled(); + expect(disposable3.dispose).toHaveBeenCalled(); + expect(disposable4.dispose).not.toHaveBeenCalled(); // now that disposableObject has been disposed, subsequent disposals are // no-ops @@ -105,10 +105,10 @@ describe("DisposableObject and DisposeHandler", () => { disposableObject.dispose(); - expect(disposable1.dispose).not.toBeCalled(); - expect(disposable2.dispose).not.toBeCalled(); - expect(disposable3.dispose).not.toBeCalled(); - expect(disposable4.dispose).not.toBeCalled(); + expect(disposable1.dispose).not.toHaveBeenCalled(); + expect(disposable2.dispose).not.toHaveBeenCalled(); + expect(disposable3.dispose).not.toHaveBeenCalled(); + expect(disposable4.dispose).not.toHaveBeenCalled(); }); class MyDisposableObject extends DisposableObject { diff --git a/extensions/ql-vscode/test/unit-tests/databases/config/db-config-store.test.ts b/extensions/ql-vscode/test/unit-tests/databases/config/db-config-store.test.ts index 33ebece8e..02e67ac7e 100644 --- a/extensions/ql-vscode/test/unit-tests/databases/config/db-config-store.test.ts +++ b/extensions/ql-vscode/test/unit-tests/databases/config/db-config-store.test.ts @@ -139,7 +139,7 @@ describe("db config store", () => { const configStore = new DbConfigStore(app, false); await configStore.initialize(); - expect(executeCommand).toBeCalledWith( + expect(executeCommand).toHaveBeenCalledWith( "setContext", "codeQLVariantAnalysisRepositories.configError", true, @@ -157,7 +157,7 @@ describe("db config store", () => { const configStore = new DbConfigStore(app, false); await configStore.initialize(); - expect(executeCommand).toBeCalledWith( + expect(executeCommand).toHaveBeenCalledWith( "setContext", "codeQLVariantAnalysisRepositories.configError", false, diff --git a/extensions/ql-vscode/test/unit-tests/model-editor/shared/access-paths.test.ts b/extensions/ql-vscode/test/unit-tests/model-editor/shared/access-paths.test.ts new file mode 100644 index 000000000..52933670b --- /dev/null +++ b/extensions/ql-vscode/test/unit-tests/model-editor/shared/access-paths.test.ts @@ -0,0 +1,251 @@ +import { + parseAccessPathTokens, + validateAccessPath, +} from "../../../../src/model-editor/shared/access-paths"; + +describe("parseAccessPathTokens", () => { + it.each([ + { + path: "Argument[foo].Element.Field[@test]", + parts: [ + { + range: { + start: 0, + end: 13, + }, + text: "Argument[foo]", + }, + { + range: { + start: 14, + end: 21, + }, + text: "Element", + }, + { + range: { + start: 22, + end: 34, + }, + text: "Field[@test]", + }, + ], + }, + { + path: "Argument[foo].Element.Field[foo.Bar.x]", + parts: [ + { + range: { + start: 0, + end: 13, + }, + text: "Argument[foo]", + }, + { + range: { + start: 14, + end: 21, + }, + text: "Element", + }, + { + range: { + start: 22, + end: 38, + }, + text: "Field[foo.Bar.x]", + }, + ], + }, + { + path: "Argument[", + parts: [ + { + range: { + start: 0, + end: 9, + }, + text: "Argument[", + }, + ], + }, + { + path: "Argument[se", + parts: [ + { + range: { + start: 0, + end: 11, + }, + text: "Argument[se", + }, + ], + }, + { + path: "Argument[foo].Field[", + parts: [ + { + range: { + start: 0, + end: 13, + }, + text: "Argument[foo]", + }, + { + range: { + start: 14, + end: 20, + }, + text: "Field[", + }, + ], + }, + { + path: "Argument[foo].", + parts: [ + { + text: "Argument[foo]", + range: { + end: 13, + start: 0, + }, + }, + { + text: "", + range: { + end: 14, + start: 14, + }, + }, + ], + }, + { + path: "Argument[foo]..", + parts: [ + { + text: "Argument[foo]", + range: { + end: 13, + start: 0, + }, + }, + { + text: "", + range: { + end: 14, + start: 14, + }, + }, + { + text: "", + range: { + end: 15, + start: 15, + }, + }, + ], + }, + { + path: "Argument[foo[bar].test].Element.", + parts: [ + { + range: { + start: 0, + end: 23, + }, + text: "Argument[foo[bar].test]", + }, + { + range: { + start: 24, + end: 31, + }, + text: "Element", + }, + { + range: { + start: 32, + end: 32, + }, + text: "", + }, + ], + }, + ])(`parses correctly for $path`, ({ path, parts }) => { + expect(parseAccessPathTokens(path)).toEqual(parts); + }); +}); + +describe("validateAccessPath", () => { + it.each([ + { + path: "Argument[foo].Element.Field[@test]", + diagnostics: [], + }, + { + path: "Argument[foo].Element.Field[foo.Bar.x]", + diagnostics: [], + }, + { + path: "Argument[", + diagnostics: [ + { + message: "Invalid access path", + range: { + start: 0, + end: 9, + }, + }, + ], + }, + { + path: "Argument[se", + diagnostics: [ + { + message: "Invalid access path", + range: { + start: 0, + end: 11, + }, + }, + ], + }, + { + path: "Argument[foo].Field[", + diagnostics: [ + { + message: "Invalid access path", + range: { + start: 14, + end: 20, + }, + }, + ], + }, + { + path: "Argument[foo].", + diagnostics: [ + { message: "Unexpected empty token", range: { start: 14, end: 14 } }, + ], + }, + { + path: "Argument[foo]..", + diagnostics: [ + { message: "Unexpected empty token", range: { start: 14, end: 14 } }, + { message: "Unexpected empty token", range: { start: 15, end: 15 } }, + ], + }, + { + path: "Argument[foo[bar].test].Element.", + diagnostics: [ + { message: "Invalid access path", range: { start: 0, end: 23 } }, + { message: "Unexpected empty token", range: { start: 32, end: 32 } }, + ], + }, + ])( + `validates $path correctly with $diagnostics.length errors`, + ({ path, diagnostics }) => { + expect(validateAccessPath(path)).toEqual(diagnostics); + }, + ); +}); diff --git a/extensions/ql-vscode/test/unit-tests/variant-analysis/variant-analysis-processor.test.ts b/extensions/ql-vscode/test/unit-tests/variant-analysis/variant-analysis-mapper.test.ts similarity index 92% rename from extensions/ql-vscode/test/unit-tests/variant-analysis/variant-analysis-processor.test.ts rename to extensions/ql-vscode/test/unit-tests/variant-analysis/variant-analysis-mapper.test.ts index caa742ceb..e443d7166 100644 --- a/extensions/ql-vscode/test/unit-tests/variant-analysis/variant-analysis-processor.test.ts +++ b/extensions/ql-vscode/test/unit-tests/variant-analysis/variant-analysis-mapper.test.ts @@ -3,10 +3,10 @@ import type { VariantAnalysisScannedRepository as ApiVariantAnalysisScannedRepos import type { VariantAnalysisScannedRepository } from "../../../src/variant-analysis/shared/variant-analysis"; import { VariantAnalysisRepoStatus } from "../../../src/variant-analysis/shared/variant-analysis"; import { - processScannedRepository, - processVariantAnalysis, - processVariantAnalysisRepositoryTask, -} from "../../../src/variant-analysis/variant-analysis-processor"; + mapScannedRepository, + mapVariantAnalysis, + mapVariantAnalysisRepositoryTask, +} from "../../../src/variant-analysis/variant-analysis-mapper"; import { createMockScannedRepo, createMockScannedRepos, @@ -17,7 +17,7 @@ import { createMockSubmission } from "../../factories/variant-analysis/shared/va import { createMockVariantAnalysisRepoTask } from "../../factories/variant-analysis/gh-api/variant-analysis-repo-task"; import { QueryLanguage } from "../../../src/common/query-language"; -describe(processVariantAnalysis.name, () => { +describe(mapVariantAnalysis.name, () => { const scannedRepos = createMockScannedRepos(); const skippedRepos = createMockSkippedRepos(); const mockApiResponse = createMockApiResponse( @@ -27,8 +27,8 @@ describe(processVariantAnalysis.name, () => { ); const mockSubmission = createMockSubmission(); - it("should process an API response and return a variant analysis", () => { - const result = processVariantAnalysis(mockSubmission, mockApiResponse); + it("should map an API response and return a variant analysis", () => { + const result = mapVariantAnalysis(mockSubmission, mockApiResponse); const { access_mismatch_repos, @@ -173,11 +173,11 @@ describe(processVariantAnalysis.name, () => { } }); -describe(processVariantAnalysisRepositoryTask.name, () => { +describe(mapVariantAnalysisRepositoryTask.name, () => { const mockApiResponse = createMockVariantAnalysisRepoTask(); it("should return the correct result", () => { - expect(processVariantAnalysisRepositoryTask(mockApiResponse)).toEqual({ + expect(mapVariantAnalysisRepositoryTask(mockApiResponse)).toEqual({ repository: { id: mockApiResponse.repository.id, fullName: mockApiResponse.repository.full_name, @@ -194,7 +194,7 @@ describe(processVariantAnalysisRepositoryTask.name, () => { }); }); -describe(processScannedRepository.name, () => { +describe(mapScannedRepository.name, () => { const mockApiResponse = createMockScannedRepo( faker.word.sample(), faker.datatype.boolean(), @@ -202,7 +202,7 @@ describe(processScannedRepository.name, () => { ); it("should return the correct result", () => { - expect(processScannedRepository(mockApiResponse)).toEqual({ + expect(mapScannedRepository(mockApiResponse)).toEqual({ repository: { id: mockApiResponse.repository.id, fullName: mockApiResponse.repository.full_name, diff --git a/extensions/ql-vscode/test/vscode-tests/activated-extension/jest.config.ts b/extensions/ql-vscode/test/vscode-tests/activated-extension/jest.config.ts index 3bd6d399c..e781b2cb3 100644 --- a/extensions/ql-vscode/test/vscode-tests/activated-extension/jest.config.ts +++ b/extensions/ql-vscode/test/vscode-tests/activated-extension/jest.config.ts @@ -4,7 +4,7 @@ import baseConfig from "../jest.config.base"; const config: Config = { ...baseConfig, - runner: "/../jest-runner-installed-extensions.ts", + runner: "/../jest-runner-vscode-codeql-cli.ts", setupFilesAfterEnv: ["/jest.setup.ts"], }; diff --git a/extensions/ql-vscode/test/vscode-tests/activated-extension/variant-analysis/variant-analysis-manager.test.ts b/extensions/ql-vscode/test/vscode-tests/activated-extension/variant-analysis/variant-analysis-manager.test.ts index 6dc783c5a..e90e3e6e9 100644 --- a/extensions/ql-vscode/test/vscode-tests/activated-extension/variant-analysis/variant-analysis-manager.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/activated-extension/variant-analysis/variant-analysis-manager.test.ts @@ -96,7 +96,7 @@ describe("Variant Analysis Manager", () => { await variantAnalysisManager.rehydrateVariantAnalysis(variantAnalysis); - expect(stub).toBeCalledTimes(1); + expect(stub).toHaveBeenCalledTimes(1); }); }); @@ -423,7 +423,7 @@ describe("Variant Analysis Manager", () => { ); expect(variantAnalysisManager.downloadsQueueSize()).toBe(0); - expect(getResultsSpy).toBeCalledTimes(3); + expect(getResultsSpy).toHaveBeenCalledTimes(3); }); }); @@ -451,7 +451,7 @@ describe("Variant Analysis Manager", () => { await variantAnalysisManager.removeVariantAnalysis(dummyVariantAnalysis); - expect(removeAnalysisResultsStub).toBeCalledTimes(1); + expect(removeAnalysisResultsStub).toHaveBeenCalledTimes(1); expect(variantAnalysisManager.variantAnalysesSize).toBe(0); await expect( @@ -610,7 +610,7 @@ describe("Variant Analysis Manager", () => { it("should return cancel if valid", async () => { await variantAnalysisManager.cancelVariantAnalysis(variantAnalysis.id); - expect(mockCancelVariantAnalysis).toBeCalledWith( + expect(mockCancelVariantAnalysis).toHaveBeenCalledWith( app.credentials, variantAnalysis, ); @@ -656,7 +656,7 @@ describe("Variant Analysis Manager", () => { variantAnalysis.id, ); - expect(writeTextStub).not.toBeCalled(); + expect(writeTextStub).not.toHaveBeenCalled(); }); }); @@ -682,7 +682,7 @@ describe("Variant Analysis Manager", () => { variantAnalysis.id, ); - expect(writeTextStub).not.toBeCalled(); + expect(writeTextStub).not.toHaveBeenCalled(); }); }); @@ -722,7 +722,7 @@ describe("Variant Analysis Manager", () => { variantAnalysis.id, ); - expect(writeTextStub).toBeCalledTimes(1); + expect(writeTextStub).toHaveBeenCalledTimes(1); }); it("should be valid JSON when put in object", async () => { diff --git a/extensions/ql-vscode/test/vscode-tests/activated-extension/variant-analysis/variant-analysis-monitor.test.ts b/extensions/ql-vscode/test/vscode-tests/activated-extension/variant-analysis/variant-analysis-monitor.test.ts index 52b58f9ca..ac7e3957e 100644 --- a/extensions/ql-vscode/test/vscode-tests/activated-extension/variant-analysis/variant-analysis-monitor.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/activated-extension/variant-analysis/variant-analysis-monitor.test.ts @@ -14,10 +14,10 @@ import type { VariantAnalysis } from "../../../../src/variant-analysis/shared/va import { VariantAnalysisStatus } from "../../../../src/variant-analysis/shared/variant-analysis"; import { createMockScannedRepos } from "../../../factories/variant-analysis/gh-api/scanned-repositories"; import { - processFailureReason, - processScannedRepository, - processUpdatedVariantAnalysis, -} from "../../../../src/variant-analysis/variant-analysis-processor"; + mapFailureReason, + mapScannedRepository, + mapUpdatedVariantAnalysis, +} from "../../../../src/variant-analysis/variant-analysis-mapper"; import { createMockVariantAnalysis } from "../../../factories/variant-analysis/shared/variant-analysis"; import { createMockApp } from "../../../__mocks__/appMock"; import { createMockCommandManager } from "../../../__mocks__/commandsMock"; @@ -88,7 +88,7 @@ describe("Variant Analysis Monitor", () => { expect(onVariantAnalysisChangeSpy).toHaveBeenCalledWith( expect.objectContaining({ status: VariantAnalysisStatus.Failed, - failureReason: processFailureReason( + failureReason: mapFailureReason( mockFailedApiResponse.failure_reason as VariantAnalysisFailureReason, ), }), @@ -121,14 +121,14 @@ describe("Variant Analysis Monitor", () => { ); await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis); - expect(mockEecuteCommand).toBeCalledTimes(succeededRepos.length); + expect(mockEecuteCommand).toHaveBeenCalledTimes(succeededRepos.length); succeededRepos.forEach((succeededRepo, index) => { expect(mockEecuteCommand).toHaveBeenNthCalledWith( index + 1, "codeQL.autoDownloadVariantAnalysisResult", - processScannedRepository(succeededRepo), - processUpdatedVariantAnalysis(variantAnalysis, mockApiResponse), + mapScannedRepository(succeededRepo), + mapUpdatedVariantAnalysis(variantAnalysis, mockApiResponse), ); }); }); @@ -197,8 +197,8 @@ describe("Variant Analysis Monitor", () => { it("should trigger a download extension command for each repo", async () => { await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis); - expect(mockGetVariantAnalysis).toBeCalledTimes(4); - expect(mockEecuteCommand).toBeCalledTimes(5); + expect(mockGetVariantAnalysis).toHaveBeenCalledTimes(4); + expect(mockEecuteCommand).toHaveBeenCalledTimes(5); }); }); @@ -261,7 +261,7 @@ describe("Variant Analysis Monitor", () => { it("should only trigger the warning once per error", async () => { await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis); - expect(logger.showWarningMessage).toBeCalledTimes(4); + expect(logger.showWarningMessage).toHaveBeenCalledTimes(4); expect(logger.showWarningMessage).toHaveBeenNthCalledWith( 1, expect.stringMatching(/No internet connection/), @@ -291,7 +291,7 @@ describe("Variant Analysis Monitor", () => { it("should not try to download any repos", async () => { await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis); - expect(mockEecuteCommand).not.toBeCalled(); + expect(mockEecuteCommand).not.toHaveBeenCalled(); }); }); diff --git a/extensions/ql-vscode/test/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml b/extensions/ql-vscode/test/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml index 1b3f20eee..6558fe72c 100644 --- a/extensions/ql-vscode/test/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml +++ b/extensions/ql-vscode/test/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml @@ -1,4 +1,4 @@ name: github/remote-query-pack version: 0.0.0 dependencies: - codeql/javascript-all: ${workspace} + codeql/javascript-all: "*" diff --git a/extensions/ql-vscode/test/vscode-tests/cli-integration/jest.config.ts b/extensions/ql-vscode/test/vscode-tests/cli-integration/jest.config.ts index ff7eac9da..fe634b66c 100644 --- a/extensions/ql-vscode/test/vscode-tests/cli-integration/jest.config.ts +++ b/extensions/ql-vscode/test/vscode-tests/cli-integration/jest.config.ts @@ -4,7 +4,7 @@ import baseConfig from "../jest.config.base"; const config: Config = { ...baseConfig, - runner: "/../jest-runner-installed-extensions.ts", + runner: "/../jest-runner-vscode-codeql-cli.ts", setupFilesAfterEnv: ["/jest.setup.ts"], // CLI integration tests call into the CLI and execute queries, so these are expected to take a lot longer // than the default 5 seconds. diff --git a/extensions/ql-vscode/test/vscode-tests/cli-integration/variant-analysis/variant-analysis-manager.test.ts b/extensions/ql-vscode/test/vscode-tests/cli-integration/variant-analysis/variant-analysis-manager.test.ts index 2c854f9ea..97fe6065f 100644 --- a/extensions/ql-vscode/test/vscode-tests/cli-integration/variant-analysis/variant-analysis-manager.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/cli-integration/variant-analysis/variant-analysis-manager.test.ts @@ -24,6 +24,8 @@ import { readBundledPack } from "../../utils/bundled-pack-helpers"; import { load } from "js-yaml"; import type { ExtensionPackMetadata } from "../../../../src/model-editor/extension-pack-metadata"; import type { QlPackLockFile } from "../../../../src/packaging/qlpack-lock-file"; +//import { expect } from "@jest/globals"; +import "../../../matchers/toExistInCodeQLPack"; describe("Variant Analysis Manager", () => { let cli: CodeQLCliServer; @@ -100,12 +102,12 @@ describe("Variant Analysis Manager", () => { const fileUri = getFile("data-remote-qlpack/in-pack.ql"); await variantAnalysisManager.runVariantAnalysis( - fileUri, + [fileUri], progress, cancellationTokenSource.token, ); - expect(executeCommandSpy).toBeCalledWith( + expect(executeCommandSpy).toHaveBeenCalledWith( "codeQL.monitorNewVariantAnalysis", expect.objectContaining({ id: mockApiResponse.id, @@ -113,20 +115,20 @@ describe("Variant Analysis Manager", () => { }), ); - expect(mockGetRepositoryFromNwo).toBeCalledTimes(1); - expect(mockSubmitVariantAnalysis).toBeCalledTimes(1); + expect(mockGetRepositoryFromNwo).toHaveBeenCalledTimes(1); + expect(mockSubmitVariantAnalysis).toHaveBeenCalledTimes(1); }); it("should run a remote query that is not part of a qlpack", async () => { const fileUri = getFile("data-remote-no-qlpack/in-pack.ql"); await variantAnalysisManager.runVariantAnalysis( - fileUri, + [fileUri], progress, cancellationTokenSource.token, ); - expect(executeCommandSpy).toBeCalledWith( + expect(executeCommandSpy).toHaveBeenCalledWith( "codeQL.monitorNewVariantAnalysis", expect.objectContaining({ id: mockApiResponse.id, @@ -134,20 +136,20 @@ describe("Variant Analysis Manager", () => { }), ); - expect(mockGetRepositoryFromNwo).toBeCalledTimes(1); - expect(mockSubmitVariantAnalysis).toBeCalledTimes(1); + expect(mockGetRepositoryFromNwo).toHaveBeenCalledTimes(1); + expect(mockSubmitVariantAnalysis).toHaveBeenCalledTimes(1); }); it("should run a remote query that is nested inside a qlpack", async () => { const fileUri = getFile("data-remote-qlpack-nested/subfolder/in-pack.ql"); await variantAnalysisManager.runVariantAnalysis( - fileUri, + [fileUri], progress, cancellationTokenSource.token, ); - expect(executeCommandSpy).toBeCalledWith( + expect(executeCommandSpy).toHaveBeenCalledWith( "codeQL.monitorNewVariantAnalysis", expect.objectContaining({ id: mockApiResponse.id, @@ -155,15 +157,15 @@ describe("Variant Analysis Manager", () => { }), ); - expect(mockGetRepositoryFromNwo).toBeCalledTimes(1); - expect(mockSubmitVariantAnalysis).toBeCalledTimes(1); + expect(mockGetRepositoryFromNwo).toHaveBeenCalledTimes(1); + expect(mockSubmitVariantAnalysis).toHaveBeenCalledTimes(1); }); it("should cancel a run before uploading", async () => { const fileUri = getFile("data-remote-no-qlpack/in-pack.ql"); const promise = variantAnalysisManager.runVariantAnalysis( - fileUri, + [fileUri], progress, cancellationTokenSource.token, ); @@ -313,13 +315,13 @@ describe("Variant Analysis Manager", () => { }) { const fileUri = getFile(queryPath); await variantAnalysisManager.runVariantAnalysis( - fileUri, + [fileUri], progress, cancellationTokenSource.token, ); - expect(mockSubmitVariantAnalysis).toBeCalledTimes(1); - expect(executeCommandSpy).toBeCalledWith( + expect(mockSubmitVariantAnalysis).toHaveBeenCalledTimes(1); + expect(executeCommandSpy).toHaveBeenCalledWith( "codeQL.monitorNewVariantAnalysis", expect.objectContaining({ query: expect.objectContaining({ filePath: fileUri.fsPath }), @@ -331,14 +333,14 @@ describe("Variant Analysis Manager", () => { const packFS = await readBundledPack(request.query.pack); filesThatExist.forEach((file) => { - expect(packFS.fileExists(file)).toBe(true); + expect(file).toExistInCodeQLPack(packFS); }); qlxFilesThatExist.forEach((file) => { - expect(packFS.fileExists(file)).toBe(true); + expect(file).toExistInCodeQLPack(packFS); }); filesThatDoNotExist.forEach((file) => { - expect(packFS.fileExists(file)).toBe(false); + expect(file).not.toExistInCodeQLPack(packFS); }); expect( @@ -364,9 +366,17 @@ describe("Variant Analysis Manager", () => { // Assume the first dependency to check is the core library. if (dependenciesToCheck.length > 0) { - expect(qlpackContents.dependencies?.[dependenciesToCheck[0]]).toEqual( - "*", - ); + const dependencyVersion = + qlpackContents.dependencies?.[dependenciesToCheck[0]]; + + // There should be a version specified. + expect(dependencyVersion).toBeDefined(); + + // Any `${workspace}` placeholder should have been replaced. + // The actual version might be `*` (for the legacy code path where we replace workspace + // references with `*`) or a specific version (for the new code path where the CLI does all + // the work). + expect(dependencyVersion).not.toEqual("${workspace}"); } const qlpackLockContents = load( packFS.fileContents("codeql-pack.lock.yml").toString("utf-8"), @@ -388,4 +398,37 @@ describe("Variant Analysis Manager", () => { } } }); + + describe("runVariantAnalysisFromPublishedPack", () => { + it("should download pack for correct language and identify problem queries", async () => { + const showQuickPickSpy = jest + .spyOn(window, "showQuickPick") + .mockResolvedValue( + mockedQuickPickItem({ + label: "JavaScript", + description: "javascript", + language: "javascript", + }), + ); + + const runVariantAnalysisMock = jest.fn(); + variantAnalysisManager.runVariantAnalysis = runVariantAnalysisMock; + + await variantAnalysisManager.runVariantAnalysisFromPublishedPack(); + + expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(runVariantAnalysisMock).toHaveBeenCalledTimes(1); + + const queries: Uri[] = runVariantAnalysisMock.mock.calls[0][0]; + // Should include queries. Just check that at least one known query exists. + // It doesn't particularly matter which query we check for. + expect( + queries.find((q) => q.fsPath.includes("PostMessageStar.ql")), + ).toBeDefined(); + // Should not include non-problem queries. + expect( + queries.find((q) => q.fsPath.includes("LinesOfCode.ql")), + ).not.toBeDefined(); + }); + }); }); diff --git a/extensions/ql-vscode/test/vscode-tests/cli.ts b/extensions/ql-vscode/test/vscode-tests/cli.ts index e875c44ea..9dca8eaf1 100644 --- a/extensions/ql-vscode/test/vscode-tests/cli.ts +++ b/extensions/ql-vscode/test/vscode-tests/cli.ts @@ -1,3 +1,4 @@ +import { basename } from "path"; import { workspace } from "vscode"; /** @@ -6,7 +7,10 @@ import { workspace } from "vscode"; */ function hasCodeQL() { const folders = workspace.workspaceFolders; - return !!folders?.some((folder) => folder.uri.path.endsWith("/codeql")); + return !!folders?.some((folder) => { + const name = basename(folder.uri.fsPath); + return name === "codeql" || name === "ql"; + }); } // describeWithCodeQL will be equal to describe if the CodeQL libraries are diff --git a/extensions/ql-vscode/test/vscode-tests/jest-runner-installed-extensions.ts b/extensions/ql-vscode/test/vscode-tests/jest-runner-installed-extensions.ts deleted file mode 100644 index 40bca3e6f..000000000 --- a/extensions/ql-vscode/test/vscode-tests/jest-runner-installed-extensions.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { spawnSync } from "child_process"; -import { dirname } from "path"; - -import type * as JestRunner from "jest-runner"; -import type { RunnerOptions } from "jest-runner-vscode"; -import VSCodeTestRunner from "jest-runner-vscode"; -import { cosmiconfig } from "cosmiconfig"; -import { - downloadAndUnzipVSCode, - resolveCliArgsFromVSCodeExecutablePath, -} from "@vscode/test-electron"; -import { ensureCli } from "./ensureCli"; - -export default class JestRunnerInstalledExtensions extends VSCodeTestRunner { - async runTests( - tests: JestRunner.Test[], - watcher: JestRunner.TestWatcher, - onStart: JestRunner.OnTestStart, - onResult: JestRunner.OnTestSuccess, - onFailure: JestRunner.OnTestFailure, - ): Promise { - // The CLI integration tests require certain extensions to be installed, which needs to happen before the tests are - // actually run. The below code will resolve the path to the VSCode executable, and then use that to install the - // required extensions. - - const installedOnVsCodeVersions = - new Set<`${RunnerOptions["version"]}-${RunnerOptions["platform"]}`>(); - - for (const test of tests) { - const testDir = dirname(test.path); - - const options: RunnerOptions = - ((await cosmiconfig("jest-runner-vscode").search(testDir)) - ?.config as RunnerOptions) ?? {}; - - const { version, platform } = options; - const versionKey = `${version}-${platform}` as const; - - if (installedOnVsCodeVersions.has(versionKey)) { - continue; - } - - const vscodeExecutablePath = await downloadAndUnzipVSCode( - version, - platform, - ); - - console.log(`Installing required extensions for ${vscodeExecutablePath}`); - - const [cli, ...args] = - resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath); - - spawnSync( - cli, - [ - ...args, - "--install-extension", - "hbenl.vscode-test-explorer", - "--install-extension", - "ms-vscode.test-adapter-converter", - ], - { - encoding: "utf-8", - stdio: "inherit", - }, - ); - - installedOnVsCodeVersions.add(versionKey); - } - - await ensureCli(true); - - return super.runTests(tests, watcher, onStart, onResult, onFailure); - } -} diff --git a/extensions/ql-vscode/test/vscode-tests/jest-runner-vscode-codeql-cli.ts b/extensions/ql-vscode/test/vscode-tests/jest-runner-vscode-codeql-cli.ts new file mode 100644 index 000000000..1d92e5331 --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/jest-runner-vscode-codeql-cli.ts @@ -0,0 +1,20 @@ +import type * as JestRunner from "jest-runner"; +import VSCodeTestRunner from "jest-runner-vscode"; +import { ensureCli } from "./ensureCli"; + +export default class JestRunnerVscodeCodeqlCli extends VSCodeTestRunner { + async runTests( + tests: JestRunner.Test[], + watcher: JestRunner.TestWatcher, + onStart: JestRunner.OnTestStart, + onResult: JestRunner.OnTestSuccess, + onFailure: JestRunner.OnTestFailure, + ): Promise { + // The CLI integration tests require the CLI to be available. We do not want to install the CLI + // when VS Code is already running because this will not give any feedback to the test runner. Instead, + // we'll download the CLI now and pass the path to the CLI to VS Code. + await ensureCli(true); + + return super.runTests(tests, watcher, onStart, onResult, onFailure); + } +} diff --git a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-queries/local-databases.test.ts b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-queries/local-databases.test.ts index faf6eaa03..f86543d62 100644 --- a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-queries/local-databases.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-queries/local-databases.test.ts @@ -130,13 +130,13 @@ describe("local databases", () => { await (databaseManager as any).addDatabaseItem(mockDbItem); expect((databaseManager as any)._databaseItems).toEqual([mockDbItem]); - expect(updateSpy).toBeCalledWith("databaseList", [ + expect(updateSpy).toHaveBeenCalledWith("databaseList", [ { options: mockDbOptions(), uri: dbLocationUri(dir).toString(true), }, ]); - expect(onDidChangeDatabaseItem).toBeCalledWith({ + expect(onDidChangeDatabaseItem).toHaveBeenCalledWith({ item: undefined, kind: DatabaseEventKind.Add, }); @@ -147,8 +147,8 @@ describe("local databases", () => { // now remove the item await databaseManager.removeDatabaseItem(mockDbItem); expect((databaseManager as any)._databaseItems).toEqual([]); - expect(updateSpy).toBeCalledWith("databaseList", []); - expect(onDidChangeDatabaseItem).toBeCalledWith({ + expect(updateSpy).toHaveBeenCalledWith("databaseList", []); + expect(onDidChangeDatabaseItem).toHaveBeenCalledWith({ item: undefined, kind: DatabaseEventKind.Remove, }); @@ -164,14 +164,14 @@ describe("local databases", () => { await databaseManager.renameDatabaseItem(mockDbItem, "new name"); expect(mockDbItem.name).toBe("new name"); - expect(updateSpy).toBeCalledWith("databaseList", [ + expect(updateSpy).toHaveBeenCalledWith("databaseList", [ { options: { ...mockDbOptions(), displayName: "new name" }, uri: dbLocationUri(dir).toString(true), }, ]); - expect(onDidChangeDatabaseItem).toBeCalledWith({ + expect(onDidChangeDatabaseItem).toHaveBeenCalledWith({ item: undefined, kind: DatabaseEventKind.Rename, }); @@ -187,7 +187,7 @@ describe("local databases", () => { await (databaseManager as any).addDatabaseItem(mockDbItem); expect(databaseManager.databaseItems).toEqual([mockDbItem]); - expect(updateSpy).toBeCalledWith("databaseList", [ + expect(updateSpy).toHaveBeenCalledWith("databaseList", [ { uri: dbLocationUri(dir).toString(true), options: mockDbOptions(), @@ -198,7 +198,7 @@ describe("local databases", () => { item: undefined, kind: DatabaseEventKind.Add, }; - expect(onDidChangeDatabaseItem).toBeCalledWith(mockEvent); + expect(onDidChangeDatabaseItem).toHaveBeenCalledWith(mockEvent); }); it("should add a database item source archive", async () => { @@ -234,9 +234,9 @@ describe("local databases", () => { await databaseManager.removeDatabaseItem(mockDbItem); expect(databaseManager.databaseItems).toEqual([]); - expect(updateSpy).toBeCalledWith("databaseList", []); + expect(updateSpy).toHaveBeenCalledWith("databaseList", []); // should remove the folder - expect(workspace.updateWorkspaceFolders).toBeCalledWith(0, 1); + expect(workspace.updateWorkspaceFolders).toHaveBeenCalledWith(0, 1); // should also delete the db contents await expect(pathExists(mockDbItem.databaseUri.fsPath)).resolves.toBe( @@ -262,9 +262,9 @@ describe("local databases", () => { await databaseManager.removeDatabaseItem(mockDbItem); expect(databaseManager.databaseItems).toEqual([]); - expect(updateSpy).toBeCalledWith("databaseList", []); + expect(updateSpy).toHaveBeenCalledWith("databaseList", []); // should remove the folder - expect(workspace.updateWorkspaceFolders).toBeCalledWith(0, 1); + expect(workspace.updateWorkspaceFolders).toHaveBeenCalledWith(0, 1); // should NOT delete the db contents await expect(pathExists(mockDbItem.databaseUri.fsPath)).resolves.toBe( @@ -279,12 +279,12 @@ describe("local databases", () => { await (databaseManager as any).addDatabaseItem(mockDbItem); // Should have registered this database - expect(registerSpy).toBeCalledWith(mockDbItem); + expect(registerSpy).toHaveBeenCalledWith(mockDbItem); await databaseManager.removeDatabaseItem(mockDbItem); // Should have deregistered this database - expect(deregisterSpy).toBeCalledWith(mockDbItem); + expect(deregisterSpy).toHaveBeenCalledWith(mockDbItem); }); }); @@ -618,7 +618,7 @@ describe("local databases", () => { it("should offer the user to set up a skeleton QL pack", async () => { await (databaseManager as any).createSkeletonPacks(mockDbItem); - expect(showNeverAskAgainDialogSpy).toBeCalledTimes(1); + expect(showNeverAskAgainDialogSpy).toHaveBeenCalledTimes(1); }); it("should return early if the user refuses help", async () => { @@ -628,7 +628,7 @@ describe("local databases", () => { await (databaseManager as any).createSkeletonPacks(mockDbItem); - expect(generateSpy).not.toBeCalled(); + expect(generateSpy).not.toHaveBeenCalled(); }); it("should return early if the user escapes out of the dialog", async () => { @@ -638,7 +638,7 @@ describe("local databases", () => { await (databaseManager as any).createSkeletonPacks(mockDbItem); - expect(generateSpy).not.toBeCalled(); + expect(generateSpy).not.toHaveBeenCalled(); }); it("should return early and write choice to settings if user wants to never be asked again", async () => { @@ -652,14 +652,14 @@ describe("local databases", () => { await (databaseManager as any).createSkeletonPacks(mockDbItem); - expect(generateSpy).not.toBeCalled(); + expect(generateSpy).not.toHaveBeenCalled(); expect(setAutogenerateQlPacksSpy).toHaveBeenCalledWith("never"); }); it("should create the skeleton QL pack for the user", async () => { await (databaseManager as any).createSkeletonPacks(mockDbItem); - expect(generateSpy).toBeCalled(); + expect(generateSpy).toHaveBeenCalled(); }); }); @@ -694,7 +694,7 @@ describe("local databases", () => { await (databaseManager as any).createSkeletonPacks(mockDbItem); - expect(generateSpy).not.toBeCalled(); + expect(generateSpy).not.toHaveBeenCalled(); }); }); }); @@ -742,7 +742,7 @@ describe("local databases", () => { mockDbItem.origin, ); - expect(resolveDatabaseContentsSpy).toBeCalledTimes(2); + expect(resolveDatabaseContentsSpy).toHaveBeenCalledTimes(2); }); it("should set the database as the currently selected one", async () => { @@ -751,7 +751,7 @@ describe("local databases", () => { mockDbItem.origin, ); - expect(setCurrentDatabaseItemSpy).toBeCalledTimes(1); + expect(setCurrentDatabaseItemSpy).toHaveBeenCalledTimes(1); }); it("should not add database source archive folder when `codeQL.addingDatabases.addDatabaseSourceToWorkspace` is `false`", async () => { @@ -762,7 +762,7 @@ describe("local databases", () => { mockDbItem.origin, ); - expect(addDatabaseSourceArchiveFolderSpy).toBeCalledTimes(0); + expect(addDatabaseSourceArchiveFolderSpy).toHaveBeenCalledTimes(0); }); it("should add database source archive folder when `codeQL.addingDatabases.addDatabaseSourceToWorkspace` is `true`", async () => { @@ -773,7 +773,7 @@ describe("local databases", () => { mockDbItem.origin, ); - expect(addDatabaseSourceArchiveFolderSpy).toBeCalledTimes(1); + expect(addDatabaseSourceArchiveFolderSpy).toHaveBeenCalledTimes(1); }); describe("when codeQL.codespacesTemplate is set to true", () => { @@ -793,7 +793,7 @@ describe("local databases", () => { { isTutorialDatabase }, ); - expect(createSkeletonPacksSpy).toBeCalledTimes(0); + expect(createSkeletonPacksSpy).toHaveBeenCalledTimes(0); }); }); @@ -806,7 +806,7 @@ describe("local databases", () => { mockDbItem.origin, ); - expect(createSkeletonPacksSpy).toBeCalledTimes(1); + expect(createSkeletonPacksSpy).toHaveBeenCalledTimes(1); }); }); }); @@ -819,7 +819,7 @@ describe("local databases", () => { mockDbItem.databaseUri, mockDbItem.origin, ); - expect(createSkeletonPacksSpy).toBeCalledTimes(0); + expect(createSkeletonPacksSpy).toHaveBeenCalledTimes(0); }); }); }); diff --git a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/queries-panel/query-tree-data-provider.test.ts b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/queries-panel/query-tree-data-provider.test.ts index 6a7670c83..b86b3ab2b 100644 --- a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/queries-panel/query-tree-data-provider.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/queries-panel/query-tree-data-provider.test.ts @@ -39,7 +39,7 @@ describe("QueryTreeDataProvider", () => { ); expect(dataProvider.getChildren()).toEqual([]); - expect(executeCommand).toBeCalledWith( + expect(executeCommand).toHaveBeenCalledWith( "setContext", "codeQL.noQueries", true, @@ -118,7 +118,7 @@ describe("QueryTreeDataProvider", () => { onDidChangeQueriesEmitter.fire(); expect(dataProvider.getChildren().length).toEqual(2); - expect(executeCommand).toBeCalledWith( + expect(executeCommand).toHaveBeenCalledWith( "setContext", "codeQL.noQueries", false, diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/distribution.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/distribution.test.ts index d3571cc06..189e1b12b 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/distribution.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/distribution.test.ts @@ -114,8 +114,8 @@ describe("Launcher path", () => { expect(result).toBe(pathToCmd); // no warning or error message - expect(warnSpy).toBeCalledTimes(0); - expect(errorSpy).toBeCalledTimes(0); + expect(warnSpy).toHaveBeenCalledTimes(0); + expect(errorSpy).toHaveBeenCalledTimes(0); }); it("should warn when deprecated launcher is used, and new launcher is available", async () => { @@ -132,8 +132,8 @@ describe("Launcher path", () => { expect(result).toBe(pathToCmd); // has warning message - expect(warnSpy).toBeCalledTimes(1); - expect(errorSpy).toBeCalledTimes(0); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(0); }); it("should warn when launcher path is incorrect", async () => { @@ -147,7 +147,7 @@ describe("Launcher path", () => { expect(result).toBeUndefined(); // no error message - expect(warnSpy).toBeCalledTimes(0); - expect(errorSpy).toBeCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(0); + expect(errorSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/external-files.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/external-files.test.ts index 26d3f525a..e6791a97c 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/external-files.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/external-files.test.ts @@ -29,7 +29,7 @@ describe("tryOpenExternalFile", () => { Uri.file("xxx"), expect.anything(), ); - expect(executeCommand).not.toBeCalled(); + expect(executeCommand).not.toHaveBeenCalled(); }); [ @@ -61,8 +61,8 @@ describe("tryOpenExternalFile", () => { const uri = Uri.file("xxx"); expect(showTextDocumentSpy).toHaveBeenCalledTimes(1); expect(showTextDocumentSpy).toHaveBeenCalledWith(uri, expect.anything()); - expect(showInformationMessageSpy).toBeCalled(); - expect(executeCommand).not.toBeCalled(); + expect(showInformationMessageSpy).toHaveBeenCalled(); + expect(executeCommand).not.toHaveBeenCalled(); }); }); }); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/progress.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/progress.test.ts index 1a1c560b6..3dad02cfb 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/progress.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/progress.test.ts @@ -17,18 +17,18 @@ describe("helpers", () => { listener({ length: firstStep }); listener({ length: secondStep }); - expect(progressSpy).toBeCalledTimes(3); - expect(progressSpy).toBeCalledWith({ + expect(progressSpy).toHaveBeenCalledTimes(3); + expect(progressSpy).toHaveBeenCalledWith({ step: 0, maxStep: max, message: "My prefix [0.0 MB of 4.0 MB]", }); - expect(progressSpy).toBeCalledWith({ + expect(progressSpy).toHaveBeenCalledWith({ step: firstStep, maxStep: max, message: "My prefix [1.6 MB of 4.0 MB]", }); - expect(progressSpy).toBeCalledWith({ + expect(progressSpy).toHaveBeenCalledWith({ step: firstStep + secondStep, maxStep: max, message: "My prefix [3.6 MB of 4.0 MB]", @@ -48,10 +48,10 @@ describe("helpers", () => { ); // There are no listeners registered to this readable - expect(mockReadable.on).not.toBeCalled(); + expect(mockReadable.on).not.toHaveBeenCalled(); - expect(progressSpy).toBeCalledTimes(1); - expect(progressSpy).toBeCalledWith({ + expect(progressSpy).toHaveBeenCalledTimes(1); + expect(progressSpy).toHaveBeenCalledWith({ step: 1, maxStep: 2, message: "My prefix (Size unknown)", diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/database-fetcher.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/database-fetcher.test.ts index e47bbb5d6..4d38cb81e 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/database-fetcher.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/database-fetcher.test.ts @@ -145,7 +145,7 @@ describe("database-fetcher", () => { await expect( convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy), ).rejects.toThrow(/Unable to get database/); - expect(progressSpy).toBeCalledTimes(0); + expect(progressSpy).toHaveBeenCalledTimes(0); }); // User has access to the repository, but there are no databases for any language. @@ -159,7 +159,7 @@ describe("database-fetcher", () => { await expect( convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy), ).rejects.toThrow(/Unable to get database/); - expect(progressSpy).toBeCalledTimes(1); + expect(progressSpy).toHaveBeenCalledTimes(1); }); describe("when language is already provided", () => { diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/ast-viewer/ast-builder.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/ast-viewer/ast-builder.test.ts index 0dc0f2b6e..fd033de5f 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/ast-viewer/ast-builder.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/ast-viewer/ast-builder.test.ts @@ -55,9 +55,9 @@ describe("AstBuilder", () => { const bqrsPath = path.normalize("/a/b/c/results.bqrs"); const options = { entities: ["id", "url", "string"] }; - expect(mockCli.bqrsDecode).toBeCalledWith(bqrsPath, "nodes", options); - expect(mockCli.bqrsDecode).toBeCalledWith(bqrsPath, "edges", options); - expect(mockCli.bqrsDecode).toBeCalledWith( + expect(mockCli.bqrsDecode).toHaveBeenCalledWith(bqrsPath, "nodes", options); + expect(mockCli.bqrsDecode).toHaveBeenCalledWith(bqrsPath, "edges", options); + expect(mockCli.bqrsDecode).toHaveBeenCalledWith( bqrsPath, "graphProperties", options, diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/ast-viewer/ast-viewer.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/ast-viewer/ast-viewer.test.ts index e7fb2cd8c..9ecee9006 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/ast-viewer/ast-viewer.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/ast-viewer/ast-viewer.test.ts @@ -86,9 +86,9 @@ describe("AstViewer", () => { const mockEvent = createMockEvent(selectionRange, fileUri); (viewer as any).updateTreeSelection(mockEvent); if (expectedSelection) { - expect(revealMock).toBeCalledWith(expectedSelection); + expect(revealMock).toHaveBeenCalledWith(expectedSelection); } else { - expect(revealMock).not.toBeCalled(); + expect(revealMock).not.toHaveBeenCalled(); } } diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/local-queries/query-resolver.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/local-queries/query-resolver.test.ts index f7fa047a5..9e4dec27b 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/local-queries/query-resolver.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/local-queries/query-resolver.test.ts @@ -49,7 +49,7 @@ describe("qlpackOfDatabase", () => { dbschemePack: "my-qlpack", dbschemePackIsLibraryPack: false, }); - expect(getPrimaryDbschemeSpy).toBeCalledWith("/path/to/database"); + expect(getPrimaryDbschemeSpy).toHaveBeenCalledWith("/path/to/database"); }); }); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts index 5ff3e9d44..69e5d3fae 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts @@ -594,7 +594,7 @@ describe("QueryHistoryManager", () => { const cancelSpy = jest.spyOn(inProgress1, "cancel"); await queryHistoryManager.handleCancel([inProgress1]); - expect(cancelSpy).toBeCalledTimes(1); + expect(cancelSpy).toHaveBeenCalledTimes(1); }); it("should cancel multiple local queries", async () => { @@ -608,8 +608,8 @@ describe("QueryHistoryManager", () => { const cancelSpy2 = jest.spyOn(inProgress2, "cancel"); await queryHistoryManager.handleCancel([inProgress1, inProgress2]); - expect(cancelSpy1).toBeCalled(); - expect(cancelSpy2).toBeCalled(); + expect(cancelSpy1).toHaveBeenCalled(); + expect(cancelSpy2).toHaveBeenCalled(); }); it("should cancel a single variant analysis", async () => { @@ -619,7 +619,7 @@ describe("QueryHistoryManager", () => { const inProgress1 = variantAnalysisHistory[1]; await queryHistoryManager.handleCancel([inProgress1]); - expect(cancelVariantAnalysisSpy).toBeCalledWith( + expect(cancelVariantAnalysisSpy).toHaveBeenCalledWith( inProgress1.variantAnalysis.id, ); }); @@ -632,10 +632,10 @@ describe("QueryHistoryManager", () => { const inProgress2 = variantAnalysisHistory[3]; await queryHistoryManager.handleCancel([inProgress1, inProgress2]); - expect(cancelVariantAnalysisSpy).toBeCalledWith( + expect(cancelVariantAnalysisSpy).toHaveBeenCalledWith( inProgress1.variantAnalysis.id, ); - expect(cancelVariantAnalysisSpy).toBeCalledWith( + expect(cancelVariantAnalysisSpy).toHaveBeenCalledWith( inProgress2.variantAnalysis.id, ); }); @@ -650,7 +650,7 @@ describe("QueryHistoryManager", () => { const cancelSpy = jest.spyOn(completed, "cancel"); await queryHistoryManager.handleCancel([completed]); - expect(cancelSpy).not.toBeCalledTimes(1); + expect(cancelSpy).not.toHaveBeenCalledTimes(1); }); it("should not cancel multiple local queries", async () => { @@ -664,8 +664,8 @@ describe("QueryHistoryManager", () => { const cancelSpy2 = jest.spyOn(failed, "cancel"); await queryHistoryManager.handleCancel([completed, failed]); - expect(cancelSpy).not.toBeCalledTimes(1); - expect(cancelSpy2).not.toBeCalledTimes(1); + expect(cancelSpy).not.toHaveBeenCalledTimes(1); + expect(cancelSpy2).not.toHaveBeenCalledTimes(1); }); it("should not cancel a single variant analysis", async () => { @@ -675,7 +675,7 @@ describe("QueryHistoryManager", () => { const completedVariantAnalysis = variantAnalysisHistory[0]; await queryHistoryManager.handleCancel([completedVariantAnalysis]); - expect(cancelVariantAnalysisSpy).not.toBeCalledWith( + expect(cancelVariantAnalysisSpy).not.toHaveBeenCalledWith( completedVariantAnalysis.variantAnalysis, ); }); @@ -691,10 +691,10 @@ describe("QueryHistoryManager", () => { completedVariantAnalysis, failedVariantAnalysis, ]); - expect(cancelVariantAnalysisSpy).not.toBeCalledWith( + expect(cancelVariantAnalysisSpy).not.toHaveBeenCalledWith( completedVariantAnalysis.variantAnalysis.id, ); - expect(cancelVariantAnalysisSpy).not.toBeCalledWith( + expect(cancelVariantAnalysisSpy).not.toHaveBeenCalledWith( failedVariantAnalysis.variantAnalysis.id, ); }); @@ -708,7 +708,7 @@ describe("QueryHistoryManager", () => { const item = localQueryHistory[4]; await queryHistoryManager.handleCopyRepoList(item); - expect(executeCommand).not.toBeCalled(); + expect(executeCommand).not.toHaveBeenCalled(); }); it("should copy repo list for a single variant analysis", async () => { @@ -718,9 +718,9 @@ describe("QueryHistoryManager", () => { const item = variantAnalysisHistory[1]; await queryHistoryManager.handleCopyRepoList(item); - expect(variantAnalysisManagerStub.copyRepoListToClipboard).toBeCalledWith( - item.variantAnalysis.id, - ); + expect( + variantAnalysisManagerStub.copyRepoListToClipboard, + ).toHaveBeenCalledWith(item.variantAnalysis.id); }); }); @@ -731,7 +731,7 @@ describe("QueryHistoryManager", () => { const item = localQueryHistory[4]; await queryHistoryManager.handleExportResults(item); - expect(variantAnalysisManagerStub.exportResults).not.toBeCalled(); + expect(variantAnalysisManagerStub.exportResults).not.toHaveBeenCalled(); }); it("should export results for a single variant analysis", async () => { @@ -739,7 +739,7 @@ describe("QueryHistoryManager", () => { const item = variantAnalysisHistory[1]; await queryHistoryManager.handleExportResults(item); - expect(variantAnalysisManagerStub.exportResults).toBeCalledWith( + expect(variantAnalysisManagerStub.exportResults).toHaveBeenCalledWith( item.variantAnalysis.id, ); }); @@ -801,7 +801,7 @@ describe("QueryHistoryManager", () => { queryHistoryManager as any ).findOtherQueryToCompare(thisQuery, [thisQuery, localQueryHistory[0]]); expect(otherQuery).toBe(localQueryHistory[0]); - expect(showQuickPickSpy).not.toBeCalled(); + expect(showQuickPickSpy).not.toHaveBeenCalled(); }); it("should throw an error when a databases are not the same", async () => { @@ -850,7 +850,7 @@ describe("QueryHistoryManager", () => { await queryHistoryManager.handleCompareWith(localQueryHistory[0], [ localQueryHistory[0], ]); - expect(doCompareCallback).not.toBeCalled(); + expect(doCompareCallback).not.toHaveBeenCalled(); }); it("should throw an error when a query is not successful", async () => { diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/variant-analysis-history.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/variant-analysis-history.test.ts index f11ba529c..041409a7e 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/variant-analysis-history.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/variant-analysis-history.test.ts @@ -119,7 +119,7 @@ describe("Variant Analyses and QueryHistoryManager", () => { it("should read query history that has variant analysis history items", async () => { await qhm.readQueryHistory(); - expect(rehydrateVariantAnalysisStub).toBeCalledTimes(2); + expect(rehydrateVariantAnalysisStub).toHaveBeenCalledTimes(2); expect(rehydrateVariantAnalysisStub).toHaveBeenNthCalledWith( 1, rawQueryHistory[0].variantAnalysis, @@ -142,8 +142,8 @@ describe("Variant Analyses and QueryHistoryManager", () => { // Add it back to the history qhm.addQuery(rawQueryHistory[0]); - expect(removeVariantAnalysisStub).toBeCalledTimes(1); - expect(rehydrateVariantAnalysisStub).toBeCalledTimes(2); + expect(removeVariantAnalysisStub).toHaveBeenCalledTimes(1); + expect(rehydrateVariantAnalysisStub).toHaveBeenCalledTimes(2); expect(qhm.treeDataProvider.allHistory).toEqual([ rawQueryHistory[1], rawQueryHistory[0], @@ -184,7 +184,9 @@ describe("Variant Analyses and QueryHistoryManager", () => { await qhm.readQueryHistory(); await qhm.handleItemClicked(qhm.treeDataProvider.allHistory[0]); - expect(showViewStub).toBeCalledWith(rawQueryHistory[0].variantAnalysis.id); + expect(showViewStub).toHaveBeenCalledWith( + rawQueryHistory[0].variantAnalysis.id, + ); }); it("should get the query text", async () => { diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-results.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-results.test.ts index 70af63221..326b32d8c 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-results.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-results.test.ts @@ -130,7 +130,7 @@ describe("query-results", () => { queryPath, "sortedResults-cc8589f226adc134f87f2438e10075e0667571c72342068e2281e0b3b65e1092.bqrs", ); - expect(spy).toBeCalledWith( + expect(spy).toHaveBeenCalledWith( expectedResultsPath, expectedSortedResultsPath, "a-result-set-name", @@ -189,7 +189,7 @@ describe("query-results", () => { ); expect(results).toEqual({ a: "1234", t: "SarifInterpretationData" }); - expect(spy).toBeCalledWith( + expect(spy).toHaveBeenCalledWith( metadata, resultsPath, interpretedResultsPath, @@ -214,7 +214,7 @@ describe("query-results", () => { sourceInfo as SourceInfo, ); expect(results).toEqual({ a: "1234", t: "SarifInterpretationData" }); - expect(spy).toBeCalledWith( + expect(spy).toHaveBeenCalledWith( { kind: "my-kind", id: "dummy-id", scored: undefined }, resultsPath, interpretedResultsPath, @@ -245,7 +245,7 @@ describe("query-results", () => { sourceInfo as SourceInfo, ); // We do not re-interpret if we are reading from a SARIF file. - expect(spy).not.toBeCalled(); + expect(spy).not.toHaveBeenCalled(); expect(results).toHaveProperty("t", "SarifInterpretationData"); expect(results).toHaveProperty("runs[0].results"); @@ -279,7 +279,7 @@ describe("query-results", () => { ); // We do not attempt to re-interpret if we are reading from a SARIF file. - expect(spy).not.toBeCalled(); + expect(spy).not.toHaveBeenCalled(); }, 2 * 60 * 1000, // up to 2 minutes per test ); @@ -336,7 +336,7 @@ describe("query-results", () => { sourceInfo as SourceInfo, ); // We do not re-interpret if we are reading from a SARIF file. - expect(spy).not.toBeCalled(); + expect(spy).not.toHaveBeenCalled(); expect(results).toHaveProperty("t", "SarifInterpretationData"); expect(results).toHaveProperty("runs[0].results"); @@ -400,7 +400,7 @@ describe("query-results", () => { ); // We do not attempt to re-interpret if we are reading from a SARIF file. - expect(spy).not.toBeCalled(); + expect(spy).not.toHaveBeenCalled(); }, 2 * 60 * 1000, // up to 2 minutes per test ); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-testing/test-adapter.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-testing/test-adapter.test.ts index 038f06353..ee7e6148c 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-testing/test-adapter.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-testing/test-adapter.test.ts @@ -1,9 +1,4 @@ -import type { - TestItem, - TestItemCollection, - TestRun, - WorkspaceFolder, -} from "vscode"; +import type { TestItem, TestItemCollection, TestRun } from "vscode"; import { CancellationTokenSource, Range, @@ -12,7 +7,6 @@ import { tests, } from "vscode"; -import { QLTestAdapter } from "../../../../src/query-testing/test-adapter"; import type { CodeQLCliServer } from "../../../../src/codeql-cli/cli"; import type { DatabaseManager } from "../../../../src/databases/local-databases"; import { mockedObject } from "../../utils/mocking.helpers"; @@ -41,50 +35,6 @@ describe("test-adapter", () => { testRunner = new TestRunner(fakeDatabaseManager, fakeCliServer); }); - it("legacy test adapter should run some tests", async () => { - const adapter = new QLTestAdapter( - mockedObject({ - name: "ABC", - uri: Uri.parse("file:/ab/c"), - }), - testRunner, - fakeCliServer, - ); - - const listenerSpy = jest.fn(); - adapter.testStates(listenerSpy); - await adapter.run([mockTestsInfo.testsPath]); - - expect(listenerSpy).toBeCalledTimes(5); - - expect(listenerSpy).toHaveBeenNthCalledWith(1, { - type: "started", - tests: [mockTestsInfo.testsPath], - }); - expect(listenerSpy).toHaveBeenNthCalledWith(2, { - type: "test", - state: "passed", - test: mockTestsInfo.dPath, - message: undefined, - decorations: [], - }); - expect(listenerSpy).toHaveBeenNthCalledWith(3, { - type: "test", - state: "errored", - test: mockTestsInfo.gPath, - message: `\ncompilation error: ${mockTestsInfo.gPath}\nERROR: abc\n`, - decorations: [{ line: 1, message: "abc" }], - }); - expect(listenerSpy).toHaveBeenNthCalledWith(4, { - type: "test", - state: "failed", - test: mockTestsInfo.hPath, - message: `\nfailed: ${mockTestsInfo.hPath}\njkh\ntuv\n`, - decorations: [], - }); - expect(listenerSpy).toHaveBeenNthCalledWith(5, { type: "finished" }); - }); - it("native test manager should run some tests", async () => { const enqueuedSpy = jest.fn(); const passedSpy = jest.fn(); @@ -145,8 +95,8 @@ describe("test-adapter", () => { const request = new TestRunRequest([rootItem]); await testManager.run(request, new CancellationTokenSource().token); - expect(enqueuedSpy).toBeCalledTimes(3); - expect(passedSpy).toBeCalledTimes(1); + expect(enqueuedSpy).toHaveBeenCalledTimes(3); + expect(passedSpy).toHaveBeenCalledTimes(1); expect(passedSpy).toHaveBeenCalledWith(childItems[0], 3000); expect(erroredSpy).toHaveBeenCalledTimes(1); expect(erroredSpy).toHaveBeenCalledWith( @@ -171,7 +121,7 @@ describe("test-adapter", () => { ], 11000, ); - expect(failedSpy).toBeCalledTimes(1); - expect(endSpy).toBeCalledTimes(1); + expect(failedSpy).toHaveBeenCalledTimes(1); + expect(endSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-testing/test-runner.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-testing/test-runner.test.ts index b7958a081..837c28a3c 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-testing/test-runner.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-testing/test-runner.test.ts @@ -94,7 +94,7 @@ describe("test-runner", () => { eventHandlerSpy, ); - expect(eventHandlerSpy).toBeCalledTimes(3); + expect(eventHandlerSpy).toHaveBeenCalledTimes(3); expect(eventHandlerSpy).toHaveBeenNthCalledWith(1, { test: mockTestsInfo.dPath, @@ -160,24 +160,24 @@ describe("test-runner", () => { setCurrentDatabaseItemSpy.mock.invocationCallOrder[0], ).toBeGreaterThan(openDatabaseSpy.mock.invocationCallOrder[0]); - expect(removeDatabaseItemSpy).toBeCalledTimes(1); - expect(removeDatabaseItemSpy).toBeCalledWith(preTestDatabaseItem); + expect(removeDatabaseItemSpy).toHaveBeenCalledTimes(1); + expect(removeDatabaseItemSpy).toHaveBeenCalledWith(preTestDatabaseItem); - expect(openDatabaseSpy).toBeCalledTimes(1); - expect(openDatabaseSpy).toBeCalledWith( + expect(openDatabaseSpy).toHaveBeenCalledTimes(1); + expect(openDatabaseSpy).toHaveBeenCalledWith( preTestDatabaseItem.databaseUri, preTestDatabaseItem.origin, false, ); - expect(renameDatabaseItemSpy).toBeCalledTimes(1); - expect(renameDatabaseItemSpy).toBeCalledWith( + expect(renameDatabaseItemSpy).toHaveBeenCalledTimes(1); + expect(renameDatabaseItemSpy).toHaveBeenCalledWith( postTestDatabaseItem, preTestDatabaseItem.name, ); - expect(setCurrentDatabaseItemSpy).toBeCalledTimes(1); - expect(setCurrentDatabaseItemSpy).toBeCalledWith( + expect(setCurrentDatabaseItemSpy).toHaveBeenCalledTimes(1); + expect(setCurrentDatabaseItemSpy).toHaveBeenCalledWith( postTestDatabaseItem, true, ); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/telemetry.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/telemetry.test.ts index 0daea3dca..93e51b45a 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/telemetry.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/telemetry.test.ts @@ -152,17 +152,17 @@ describe("telemetry reporting", () => { expect(telemetryListener._reporter).toBeDefined(); expect(telemetryListener._reporter).not.toBe(firstReporter); - expect(disposeSpy).toBeCalledTimes(1); + expect(disposeSpy).toHaveBeenCalledTimes(1); // initializing a third time continues to dispose await telemetryListener.initialize(); - expect(disposeSpy).toBeCalledTimes(2); + expect(disposeSpy).toHaveBeenCalledTimes(2); }); it("should reinitialize reporter when extension setting changes", async () => { await telemetryListener.initialize(); - expect(disposeSpy).not.toBeCalled(); + expect(disposeSpy).not.toHaveBeenCalled(); expect(telemetryListener._reporter).toBeDefined(); // this disables the reporter @@ -170,13 +170,13 @@ describe("telemetry reporting", () => { expect(telemetryListener._reporter).toBeUndefined(); - expect(disposeSpy).toBeCalledTimes(1); + expect(disposeSpy).toHaveBeenCalledTimes(1); // creates a new reporter, but does not dispose again await enableTelemetry("codeQL.telemetry", true); expect(telemetryListener._reporter).toBeDefined(); - expect(disposeSpy).toBeCalledTimes(1); + expect(disposeSpy).toHaveBeenCalledTimes(1); }); it("should set userOprIn to false when global setting changes", async () => { @@ -205,7 +205,7 @@ describe("telemetry reporting", () => { }, { executionTime: 1234 }, ); - expect(sendTelemetryErrorEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toHaveBeenCalled(); }); it("should send a command usage event with an error", async () => { @@ -227,7 +227,7 @@ describe("telemetry reporting", () => { }, { executionTime: 1234 }, ); - expect(sendTelemetryErrorEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toHaveBeenCalled(); }); it("should send a command usage event with a cli version", async () => { @@ -250,7 +250,7 @@ describe("telemetry reporting", () => { }, { executionTime: 1234 }, ); - expect(sendTelemetryErrorEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toHaveBeenCalled(); // Verify that if the cli version is not set, then the telemetry falls back to "not-set" sendTelemetryEventSpy.mockClear(); @@ -272,7 +272,7 @@ describe("telemetry reporting", () => { }, { executionTime: 5678 }, ); - expect(sendTelemetryErrorEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toHaveBeenCalled(); }); it("should avoid sending an event when telemetry is disabled", async () => { @@ -282,8 +282,8 @@ describe("telemetry reporting", () => { telemetryListener.sendCommandUsage("command-id", 1234, undefined); telemetryListener.sendCommandUsage("command-id", 1234, new Error()); - expect(sendTelemetryEventSpy).not.toBeCalled(); - expect(sendTelemetryErrorEventSpy).not.toBeCalled(); + expect(sendTelemetryEventSpy).not.toHaveBeenCalled(); + expect(sendTelemetryErrorEventSpy).not.toHaveBeenCalled(); }); it("should send an event when telemetry is re-enabled", async () => { @@ -303,7 +303,7 @@ describe("telemetry reporting", () => { }, { executionTime: 1234 }, ); - expect(sendTelemetryErrorEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toHaveBeenCalled(); }); it("should filter undesired properties from telemetry payload", async () => { @@ -361,7 +361,7 @@ describe("telemetry reporting", () => { await wait(500); // Dialog opened, user clicks "yes" and telemetry enabled - expect(showInformationMessageSpy).toBeCalledTimes(1); + expect(showInformationMessageSpy).toHaveBeenCalledTimes(1); expect(ENABLE_TELEMETRY.getValue()).toBe(true); expect(ctx.globalState.get("telemetry-request-viewed")).toBe(true); }); @@ -374,7 +374,7 @@ describe("telemetry reporting", () => { await telemetryListener.initialize(); // Dialog opened, user clicks "no" and telemetry disabled - expect(showInformationMessageSpy).toBeCalledTimes(1); + expect(showInformationMessageSpy).toHaveBeenCalledTimes(1); expect(ENABLE_TELEMETRY.getValue()).toBe(false); expect(ctx.globalState.get("telemetry-request-viewed")).toBe(true); }); @@ -387,7 +387,7 @@ describe("telemetry reporting", () => { await enableTelemetry("codeQL.telemetry", false); // Dialog opened, and user closes without interacting with it - expect(showInformationMessageSpy).toBeCalledTimes(1); + expect(showInformationMessageSpy).toHaveBeenCalledTimes(1); expect(ENABLE_TELEMETRY.getValue()).toBe(false); // dialog was canceled, so should not have marked as viewed expect(ctx.globalState.get("telemetry-request-viewed")).toBe(false); @@ -406,7 +406,7 @@ describe("telemetry reporting", () => { // Dialog opened, and user closes without interacting with it // Telemetry state should not have changed - expect(showInformationMessageSpy).toBeCalledTimes(1); + expect(showInformationMessageSpy).toHaveBeenCalledTimes(1); expect(ENABLE_TELEMETRY.getValue()).toBe(true); // dialog was canceled, so should not have marked as viewed expect(ctx.globalState.get("telemetry-request-viewed")).toBe(false); @@ -426,7 +426,7 @@ describe("telemetry reporting", () => { await telemetryListener.initialize(); // popup should not be shown even though we have initialized telemetry - expect(showInformationMessageSpy).not.toBeCalled(); + expect(showInformationMessageSpy).not.toHaveBeenCalled(); }); // This test is failing because codeQL.canary is not a registered configuration. @@ -447,10 +447,10 @@ describe("telemetry reporting", () => { // now, we should have to click through the telemetry requestor again expect(ctx.globalState.get("telemetry-request-viewed")).toBe(false); - expect(showInformationMessageSpy).toBeCalledTimes(1); + expect(showInformationMessageSpy).toHaveBeenCalledTimes(1); }); - it("should send a ui-interaction telementry event", async () => { + it("should send a ui-interaction telemetry event", async () => { await telemetryListener.initialize(); telemetryListener.sendUIInteraction("test"); @@ -464,10 +464,10 @@ describe("telemetry reporting", () => { }, {}, ); - expect(sendTelemetryErrorEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toHaveBeenCalled(); }); - it("should send a ui-interaction telementry event with a cli version", async () => { + it("should send a ui-interaction telemetry event with a cli version", async () => { await telemetryListener.initialize(); telemetryListener.cliVersion = new SemVer("1.2.3"); @@ -482,15 +482,15 @@ describe("telemetry reporting", () => { }, {}, ); - expect(sendTelemetryErrorEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toHaveBeenCalled(); }); - it("should send an error telementry event", async () => { + it("should send an error telemetry event", async () => { await telemetryListener.initialize(); telemetryListener.sendError(redactableError`test`); - expect(sendTelemetryEventSpy).not.toBeCalled(); + expect(sendTelemetryEventSpy).not.toHaveBeenCalled(); expect(sendTelemetryErrorEventSpy).toHaveBeenCalledWith( "error", { @@ -503,13 +503,13 @@ describe("telemetry reporting", () => { ); }); - it("should send an error telementry event with a cli version", async () => { + it("should send an error telemetry event with a cli version", async () => { await telemetryListener.initialize(); telemetryListener.cliVersion = new SemVer("1.2.3"); telemetryListener.sendError(redactableError`test`); - expect(sendTelemetryEventSpy).not.toBeCalled(); + expect(sendTelemetryEventSpy).not.toHaveBeenCalled(); expect(sendTelemetryErrorEventSpy).toHaveBeenCalledWith( "error", { @@ -529,7 +529,7 @@ describe("telemetry reporting", () => { redactableError`test message with secret information: ${42} and more ${"secret"} parts`, ); - expect(sendTelemetryEventSpy).not.toBeCalled(); + expect(sendTelemetryEventSpy).not.toHaveBeenCalled(); expect(sendTelemetryErrorEventSpy).toHaveBeenCalledWith( "error", { @@ -543,6 +543,27 @@ describe("telemetry reporting", () => { ); }); + it("should send config telemetry event", async () => { + await telemetryListener.initialize(); + + telemetryListener.sendConfigInformation({ + testKey: "testValue", + testKey2: "42", + }); + + expect(sendTelemetryEventSpy).toHaveBeenCalledWith( + "config", + { + testKey: "testValue", + testKey2: "42", + isCanary: "false", + cliVersion: "not-set", + }, + {}, + ); + expect(sendTelemetryErrorEventSpy).not.toHaveBeenCalled(); + }); + async function enableTelemetry(section: string, value: boolean | undefined) { await workspace .getConfiguration(section) diff --git a/extensions/ql-vscode/test/vscode-tests/utils/bundled-pack-helpers.ts b/extensions/ql-vscode/test/vscode-tests/utils/bundled-pack-helpers.ts index a49130c40..f572537bf 100644 --- a/extensions/ql-vscode/test/vscode-tests/utils/bundled-pack-helpers.ts +++ b/extensions/ql-vscode/test/vscode-tests/utils/bundled-pack-helpers.ts @@ -4,10 +4,11 @@ import { extract as tar_extract } from "tar-stream"; import { pipeline } from "stream/promises"; import { createGunzip } from "zlib"; -interface QueryPackFS { +export interface QueryPackFS { fileExists: (name: string) => boolean; fileContents: (name: string) => Buffer; directoryContents: (name: string) => string[]; + allFiles: () => string[]; } export async function readBundledPack( @@ -82,5 +83,8 @@ export async function readBundledPack( ) .map((dir) => dir.substring(name.length + 1)); }, + allFiles: (): string[] => { + return Object.keys(files); + }, }; }