diff --git a/.gitignore b/.gitignore index 427675f91..b7f4def1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ _jest-editor/ .vscode/symbols.json -/coverage/ +/coverage*/ node_modules/ out/ generated-icons/ @@ -8,3 +8,4 @@ generated-icons/ **/.DS_Store *.vsix *.zip +coverage_comparison_report.html diff --git a/.vscodeignore b/.vscodeignore index 2ad34b81b..9e4a8f451 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -28,3 +28,7 @@ generated-icons/ release-notes/ setup-wizard.md *.zip +coverage*/ +scripts/** +coverage_comparison_report.html + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f4de8e99..c7bdb8fdc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,10 +104,24 @@ There are two debugging launch configurations defined in `.vscode/launch.json`: To debug the extension, [change the launch configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) to **Launch Extension** and start debugging. -**3. eat our own dog food** +**3. check test coverage** + +Make sure your changes are covered by unit tests and that you did not lose coverage compared to the master branch. While compared to master branch is part of PR workflow validation, it is always a good practice to check it locally before submitting the PR. The following is the step to do it: +1. go back to master branch (`git checkout master`), run all tests with coverage, which should produce a "coverage" folder under the project root. Rename it to something like "coverage-master" to keep it for later comparison. +2. switch back to your branch (`git checkout your-branch`), run all tests with coverage, which should produce a "coverage" folder under the project root. +3. run compare-coverage script with the master and the current coverage folders, such as `yarn compare-coverage coverage-master coverage`. It will produce a report `coverage_comparison_report.html` under the project root. +4. examine `coverage_comparison_report.html` to make sure your changes are fully tested therefore did not reduce coverage compared to master branch. + +**4. eat our own dog food** The ultimate test is to actually use it in our real day-to-day working environment for a while. There are multiple ways to do this: +The most accurate way is to install the extension locally and use it in your real project. You can do this by: +- building a local installable package with command: `vsce package`. This will produce a `vscode-jest-xxx.vsix` file, where "xxx" is the version number in `package.json`. +- install this file by using vscode command palette `Extensions: Install from VSIX...`. +- after test, you can restore the official version from the vscode's extensions panel. + +If you don't have vsce installed, you can try these methods instead: - by command line: `code --extensionDevelopmentPath=your-local-vscode-jest` - by environment variable: `CODE_EXTENSIONS_PATH` - by symlink: diff --git a/README.md b/README.md index 96d8d8c7f..2d5b68d1e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ If the extension can find the jest command, by default it will automatically run If you have a more sophisticated project configuration or prefer to run tests differently, fear not, the extension supports extensive [customization settings](#customization). For example: - you can use [jest.jestCommandLine](#jestcommandline) to tell the extension to use `yarn test` instead of the default jest command. -- you can use [jest.autoRun](#autorun) to optimize performance and control when the extension should run your tests. +- you can use [jest.runMode](#runmode) to optimize performance and control when the extension should run your tests. - you can use the extension with monorepo projects, see [monorepo project support](#how-to-use-the-extension-with-monorepo-projects) for details. You can see the full [features](#features) and learn more details in the [How-To](#how-to) section. If you encounter an unexpected error, feel free to checkout the [Troubleshooting](#troubleshooting) or file an [issue](https://github.com/jest-community/vscode-jest/issues). @@ -40,31 +40,33 @@ Content - [Releases](#releases) - [Features](#features) - [Installation](#installation) + - [User Interface](#user-interface) - [How To?](#how-to) - [How to set up the extension?](#how-to-set-up-the-extension) - - [How to trigger the test run?](#how-to-trigger-the-test-run) + - [How to change runMode for the current session?](#how-to-change-runmode-for-the-current-session) + - [How to trigger a test run?](#how-to-trigger-a-test-run) - [How to debug tests?](#how-to-debug-tests) - [How to use code coverage?](#how-to-use-code-coverage) + - [How to read coverage scheme and customize it](#how-to-read-coverage-scheme-and-customize-it) - [How to update and view snapshots](#how-to-update-and-view-snapshots) - [How to use the extension with monorepo projects?](#how-to-use-the-extension-with-monorepo-projects) - [How to read the StatusBar?](#how-to-read-the-statusbar) - - [How to use the Test Explorer?](#how-to-use-the-test-explorer) + - [How to perform Quick-Fix?](#how-to-perform-quick-fix) - [How to see more debug info (self-diagnosis)?](#how-to-see-more-debug-info-self-diagnosis) - [Customization](#customization) - [Settings](#settings) - - [Details](#details) - - [jestCommandLine](#jestcommandline) - - [rootPath](#rootpath) - - [coverageFormatter](#coverageformatter) - - [coverageColors](#coveragecolors) - - [autoRun](#autorun) - - [testExplorer](#testexplorer) - - [shell](#shell) - - [monitorLongRun](#monitorlongrun) - - [autoRevealOutput](#autorevealoutput) - - [parserPluginOptions](#parserpluginoptions) - - [useDashedArgs](#usedashedargs) - - [virtualFolders](#virtualfolders) + - [jestCommandLine](#jestcommandline) + - [rootPath](#rootpath) + - [coverageFormatter](#coverageformatter) + - [coverageColors](#coveragecolors) + - [runMode](#runmode) + - [autoRun](#autorun) + - [testExplorer](#testexplorer) + - [shell](#shell) + - [monitorLongRun](#monitorlongrun) + - [autoRevealOutput](#autorevealoutput) + - [parserPluginOptions](#parserpluginoptions) + - [virtualFolders](#virtualfolders) - [Debug Config](#debug-config) - [Debug Config v2](#debug-config-v2) - [Commands](#commands) @@ -101,31 +103,74 @@ Alternatively open Visual Studio Code, go to the extension view and search for " For detailed releases and migration help, please see [releases](https://github.com/jest-community/vscode-jest/releases). +## User Interface + +This extension fully integrates with the VSCode testing framework, supporting both automatic and on-demand test runs. In addition to the standard VSCode Test Explorer interface, the extension provides additional UI elements to simplify the development workflow: + +testExplorer.png + +1. **Run Mode**: [Run mode](#runmode) dictates the overall user experience, determining when tests should run, how test outputs are displayed or cleared, etc. Different run modes may have different performance implications, particularly for larger projects. The run mode is visible in the Test Explorer (on the folder item) and on the status bar (refer to point 4). Users can change this setting either in `settings.json` (for all runs) or through the UI components mentioned below (for the current session): + - **1.1** Adjust the RunMode using the Test Explorer's run configuration dropdown by selecting "Configure Test Profiles". + - **1.2** Alternatively, alter the RunMode via the Test Explorer's inline menu (associated with the folder item). + + More info: + - [How to change runMode](#runmode-chooser). + - [RunMode performance tradeoff](#runmode-tradeoff) + +2. **Test Menu**: Besides automatic test executions (if configured), users can initiate test actions on-demand via: + - **2.1** The Test Explorer inline menu for individual test items. A right/alt click on an item also displays a context menu similar to the editor's gutter menu below. + - **2.2** Right/Alt clicking on the test status indicator in the editor's gutter shows a context menu with relevant test actions, such as run, debug, view, or update snapshots. + + More info: + - [How to trigger a test run](#how-to-trigger-the-test-run) + - [How to update and view snapshots](#how-to-update-and-view-snapshots). + +3. **Test Output**: Alongside the test status visible in the editor and TestExplorer tree view, the extension offers native jest run outputs, extension configuration details, and [quick-fix](#quick-fix-chooser) suggestions through vscode terminals (labeled as "Jest (folder-name)"). By default, the output automatically appears when tests run, but this can be adjusted via the [runMode](#runmode) setting. + - **3.1** The output terminal can be accessed on-demand using the TestExplorer inline menu (on the folder item) or the status bar item (refer to point 4). + +4. **Extension Status**: Upon successful launch of the extension, it displays the active folder run status and the aggregated workspace test stats in the status bar. Clicking on the item reveals the associated output window. + + More info: + - [How to read the StatusBar](#how-to-read-the-statusbar) + + ## How To? ### How to set up the extension? Hopefully, you don't have to do anything. If you can run jest from the terminal, you should be able to use this extension. -The extension will try to auto-config a jest command and debug config when needed. If the auto-config fails, users should see an error panel with the `"Fix"` option to help them fix the settings. - -A few known failure scenarios: -- PNP without node_modules nor a "test" script in package.json will need to set up jest.jestCommandLine explicitly. -- Multi-root monorepo project in a single-root workspace will need to be converted to a multi-root project first. From v5, you can quickly perform this with the [monorepo setup tool](setup-wizard.md#setup-monorepo-project). +The extension will try to auto-config a jest command and debug config when needed. If the auto-config fails, or any non-test error occurred, users should see an [quick-fix-chooser](#quick-fix-chooser) link in the output terminal to help resolving the issues. For more details see the [setup tool](setup-wizard.md) and the complete customization options in [settings](#settings). -### How to trigger the test run? +### How to change runMode for the current session? + +To adjust the runMode during your current session: + +1. Click on the runMode buttons referenced in the [User Interface](#user-interface) section. This action will prompt a runMode chooser. -By default, the extension uses jest watch mode that automatically runs tests upon related file changes. In addition, users can also trigger individual tests/suites interactively: +2. Within the chooser, you can swiftly switch the runMode type, toggle coverage, opt for deferred mode, or directly edit the runMode within an editor. + +runMode Chooser Interface + +3. Confirm your adjustments by clicking the "Apply" button located on the top right. Or discard changes with the "Return" button on the top left. + +Note: Changes to runMode using the UI will apply only to the ongoing session. Upon restarting the extension or reloading the window, the runMode will revert to the value in settings.json. However, you can write the current runMode value to `settings.json` with command: `"Jest: Save Current RunMode"` via command palette. + +For an in-depth understanding of each type and the available options, refer to [runMode](#runmode). + +### How to trigger a test run? + +By default, the extension uses jest watch mode that automatically runs tests upon related file changes. In addition, users can also trigger individual tests/suites on-demand: run-test 1. trigger test runs via the gutter menu of each test and describe blocks. -2. trigger test runs via the test tree inline menu. +2. trigger test runs via the TestExplorer tree item's inline menu. 3. trigger test runs via command palette, such as `Jest: Run All Tests` 4. trigger test runs via the editor context menu: `Jest: Run Related Tests` -The extension will try to auto-config a jest runner, if it fails, you can try the `"Fix"` button in the error panel or checkout the [troubleshooting](#troubleshooting). +The extension will try to auto-config a jest runner, if it fails, you can try the [quick-fix-chooser](#quick-fix-chooser). ### How to debug tests? There are 2 ways to debug a specific test: @@ -140,32 +185,28 @@ The extension will try to generate a debug config, but if you encounter a debug ### How to use code coverage? Code coverage can be triggered via -1. test tree item inline menu (see [toggle coverage](how-to-toggle-coverage)) +1. change [runMode](#runmode) config via [chooser](#run-modoe-chooser) or settings. 2. [Command Palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette), select command like **Jest: Toggle Coverage** to activate or deactivate code coverage (see full list in [commands](#commands). -The coverage state is reflected in test tree toggle menu, as well as StatusBar: +The coverage stats will be displayed at the source code editor: summary at the top and inline coverage indicator based on the formatter selected. -status-bar-modes +coverage-screen-shot -This extension supports both `babel` and `v8` coverageProviders. However, please note the coverage might not be exactly the same, see [facebook/jest#11188](https://github.com/facebook/jest/issues/11188) for more details. +To verify the coverage mode is on, one can check the [runMode chooser](#run-mode-choose) or the status bar (see [how to read the status bar](#how-to-read-the-statusbar)) -
+Note: this extension supports both `babel` and `v8` coverageProviders. However, please note the coverage might not be exactly the same, see [facebook/jest#11188](https://github.com/facebook/jest/issues/11188) for more details. -How to read coverage scheme and customize it +#### How to read coverage scheme and customize it In addition to the coverage summary that is shown on the top of the file, each line will be marked by its coverage status according to the coverage formatter configured. There are 3 types of coverage you might see in your source code, distinguished by colors: - "covered": if the code is covered. Either not marked or "green" by default, depends on the formatter. - "not-covered": if the code is not covered. Marked as "red" by default. - "partially-covered": Usually this mean the branch (such as if, switch statements) only partially tested. Marked as "yellow" by default. - - _Please note, istanbuljs (the library jest used to generate coverage info) reports switch branch coverage with the first "case" statement instead of the "switch" statement._ - -![coverage-screen-shot](images/coverage-screen-shot.png) + - Please note, istanbuljs (the library jest used to generate coverage info) reports switch branch coverage with the first "case" statement instead of the "switch" statement. You can customize coverage start up behavior, style and colors, see [customization](#customization) for more details. -
- ### How to update and view snapshots snapshot-menu @@ -189,54 +230,29 @@ For option 1 and 2, the easiest way to setup the monorepo projects is to use the Please note, a working jest environment is a prerequisite for this extension. If you are having problem running the tests from a terminal, please follow [jest](https://jestjs.io/docs/configuration) instruction to set it up first. ### How to read the StatusBar? -StatusBar shows 2 kinds of information: -`Jest` shows the mode and state of the "active" workspace folder or virtual folder(s). -`Jest-WS` shows the total test suite stats for the whole workspace. -Clicking on each of these button will reveal the corresponding output window with more details. - -
-Illustration - - - - -shows the active workspace has coverage on. - +StatusBar shows 2 types of information, illustrated below: -shows the active workspace has onSave for test file only, and that the workspace stats is out of sync with the code, such as when the source file is changed but the related tests are not run yet. +snapshot-menu - +1. `Jest (folder-name)`: shows the mode and state of the "active" workspace folder or virtual folder (based on the active editor). As the hovering text showed, the "active" folder "react-ts" is in "watch" mode with coverage on. +2. `Jest-WS`: shows the total test suite stats (pass, failed, unknown) for the whole workspace. Unknown test suites usually mean the test files are not yet run. -shows the autoRun will be triggered by either test or source file changes. +Hovering over the status items will show tooltips with descriptions; clicking on the items open the corresponding output window. - +### How to perform Quick-Fix? + +Upon non-test errors, such as jest command not found, the extension can help user fix common errors with quick fix: -shows active workspace has an execution error. -
- -### How to use the Test Explorer? -Users with `vscode` v1.59 and `vscode-jest` v4.1 and up will start to see tests appearing in the test explorer automatically. Test explorer provides a "test-centric" view, allows users to run/debug tests directly from the explorer, and provides a native terminal output experience (with colors!): - -testExplorer.png - -**How to toggle autoRun for the workspace?** -- In TestExplorer, click on the root of the test tree, i.e. the one with the workspace name and the current autoRun mode. You will see a list of buttons to its right. -- Click on the [autoRun](#autorun) button (see image above) to toggle it on or off. - - If autoRun is originally on, the button will turn it off and users can use the run menu (in both editor gutter and test explorer tree) to trigger test run(s). - - If the autoRun is originally off, the button will turn it on by restoring to your original autoRun setting, if it is not "off", otherwise it will switch to ["on-save"](#autorun) mode instead. +coverage-screen-shot -**How to toggle test coverage for the workspace?** -- In TestExplorer, click on the root of the test tree, i.e. the one with the workspace name and the current autoRun mode. You will see a list of buttons to its right. -- Click on the coverage button (see image above) to toggle on or off. - - The next test run (auto or manual) will start reporting test coverage. - -**How to reveal test output for the workspace?** -- In TestExplorer, click on the root of the test tree, i.e. the one with the workspace name and the current autoRun mode. You will see a list of buttons to its right. -- Click on the terminal button (see image above) to reveal the test output terminal. - - -You can further customize the explorer with [jest.testExplorer](#testexplorer) in [settings](#settings). +1. `cmd + click` on the quick fix link in the output terminal will open a quick-fix chooser. +2. A quick-fix chooser listed the fixes and hints to help resolve common errors: + - **Customize Extensions**: If you can run jest with CLI in the terminal but not with the extension, chances are you might need to configure the `jest.jestCommandLine`, `jest.rootPath`, monorepo settings, etc. This action will open the [setup tools](setup-wizard.md) to continue. + - **Defer or Change Run Mode**: If the project has not yet ready to run jest, you can use this action to defer the test run or change the [runMode](#runmode) to on-demand mode. This action brings up a [runMode chooser](#runmode-chooser) to continue. + - **Disable Extension**: If you do not intend to run jest for this folder, you can choose this action to disable the extension. This action will set `"jest.enable": false` in the `.vscode/settings.json` file. + - **Help**: If you are not sure what to do, you can choose this action to open the [troubleshooting](#troubleshooting) page. + ### How to see more debug info (self-diagnosis)? @@ -249,46 +265,49 @@ It is sometimes helpful to see the actual command and shell environment spawned, ### Settings Users can use the following settings to tailor the extension for their environments. - All settings are prefixed with `jest` and saved in standard `.vscode/settings.json`. -- settings crossed out are to be deprecated in the future -- settings marked with 💼 apply to the whole project, otherwise per workspace. +- settings marked with :x: are deprecated and subject to removal in the future. +- settings marked with 💼 apply to the whole workspace, otherwise on workspace-folder level. -|setting|description|default|example/notes|available since| +|setting|description|default|example/notes|available| |---|---|---|---|---| |**Process**| -|enable|Enable/disable jest extension for the given workspace folder/virtual-folder|true|`"jest.enable": false`| >= v6.0.0| |[jestCommandLine](#jestcommandline)|The command line to start jest tests|undefined|`"jest.jestCommandLine": "npm test --"` or `"jest.jestCommandLine": "yarn test"` or `"jest.jestCommandLine": "node_modules/.bin/jest --config custom-config.js"`| +|[rootPath](#rootPath)|The path to your frontend src folder|""|`"jest.rootPath":"packages/app"` or `"jest.rootPath":"/apps/my-app"`| |nodeEnv|Add additional env variables to spawned jest process|null|`"jest.nodeEnv": {"PORT": "9800", "BAR":"true"}` | -|[shell](#shell)|shell (path or LoginShell) for executing jest|null|`"jest.shell": "/bin/bash"` or `"jest.shell": "powershell"` or `"jest.shell": {"path": "/bin/bash"; args: ["--login"]}` || -|[autoRun](#autorun)|Controls when and what tests should be run|undefined|`"jest.autoRun": "off"` or `"jest.autoRun": "watch"` or `"jest.autoRun": {"watch": false, "onSave":"test-only"}`|| -|[rootPath](#rootPath)|The path to your frontend src folder|""|`"jest.rootPath":"packages/app"` or `"jest.rootPath":"/apps/my-app"`|| -[useDashedArgs](#usedashedargs)| Determine if to use dashed arguments for jest processes |undefined|`"jest.useDashedArgs":true`| >= v6.0.0 | -|[monitorLongRun](#monitorlongrun)| monitor long running tests based on given threshold in ms|60000|`"jest.monitorLongRun": 120000`|| -|[parserPluginOptions](#parserpluginoptions)|Configure babel parser plugins|null|`"jest.parserPluginOptions": {decorators: 'legacy'}`|| -|[virtualFolders](#virtual-folders)|defines multiple jest runs in a given vscode workspace folder|undefined|`"jest.virtualFolders": "[{"name": "front-end", "rootPath': "packages/front-end"}, {"name": "back-end", "rootPath': "packages/back-end"} ]"`| >= v6.0.0| -|**Editor**| +|[shell](#shell)|shell (path or LoginShell) for executing jest|null|`"jest.shell": "/bin/bash"` or `"jest.shell": "powershell"` or `"jest.shell": {"path": "/bin/bash"; args: ["--login"]}` | +useDashedArgs| Determine if to use dashed arguments for jest processes |undefined|`"jest.useDashedArgs":true`| >= v6.0.0 +|[parserPluginOptions](#parserpluginoptions)|Configure babel parser plugins|null|`"jest.parserPluginOptions": {decorators: 'legacy'}`| +|[virtualFolders](#virtual-folders)|defines multiple jest runs in a given vscode workspace folder|undefined|`"jest.virtualFolders": "[{"name": "front-end", "rootPath': "packages/front-end"}, {"name": "back-end", "rootPath': "packages/back-end"} ]"`| >= v6.0.0 +|**UX**| +|[runMode](#runmode)|Controls most test UX, including when tests should be run, output management, etc|undefined|`"jest.runMode": "watch"` or `"jest.runMode": "on-demand"` or `"jest.runMode": {"type": "on-demand", "deferred": true}`| >= v6.0.2 +|autoClearTerminal|Clear the terminal output at the start of any new test run.|false|`"jest.autoClearTerminal": true`| >= v6.0.0 |[testExplorer](#testexplorer) |Configure jest test explorer|null|`{"showInlineError": "true"}`| +|:x: [autoRun](#autorun)|Controls when and what tests should be run|undefined|`"jest.autoRun": "off"` or `"jest.autoRun": "watch"` or `"jest.autoRun": {"watch": false, "onSave":"test-only"}`| <= v6.0.2 (replaced by runMode) +|:x: [autoRevealOutput](#autoRevealOutput)|Determine when to show test output|"on-run"|`"jest.autoRevealOutput": "on-exec-error"`| <= v6.0.2 (replaced by runMode) |**Coverage**| -|showCoverageOnLoad|Show code coverage when extension starts|false|`"jest.showCoverageOnLoad": true`| +|:x: showCoverageOnLoad|Show code coverage when extension starts|false|`"jest.showCoverageOnLoad": true`| <= v6.0.2 (replaced by runMode) |[coverageFormatter](#coverageFormatter)|Determine the coverage overlay style|"DefaultFormatter"|`"jest.coverageFormatter": "GutterFormatter"`| |[coverageColors](#coverageColors)|Coverage indicator color override|undefined|`"jest.coverageColors": { "uncovered": "rgba(255,99,71, 0.2)", "partially-covered": "rgba(255,215,0, 0.2)"}`| |**Misc**| -|debugMode|Enable debug mode to diagnose plugin issues. (see developer console)|false|`"jest.debugMode": true`| +|enable|Enable/disable jest extension for the given workspace folder/virtual-folder|true|`"jest.enable": false`|>=6.0.0| |disabledWorkspaceFolders 💼|Disabled workspace folders names in multi-root environment|[]|`"jest.disabledWorkspaceFolders": ["package-a", "package-b"]`| -|[autoRevealOutput](#autoRevealOutput)|Determine when to show test output|"on-run"|`"jest.autoRevealOutput": "on-exec-error"`| -|autoClearTerminal|Clear the terminal output at the start of any new test run.|false|`"jest.autoClearTerminal": true`| >= v6.0.0 | +|debugMode|Enable debug mode to diagnose plugin issues. (see developer console)|false|`"jest.debugMode": true`| +|[monitorLongRun](#monitorlongrun)| monitor long running tests based on given threshold in ms|60000|`"jest.monitorLongRun": 120000`| -#### Details -##### jestCommandLine + +#### jestCommandLine This should be the command users used to kick off the jest tests in the terminal. However, since the extension will append additional options at run time, please make sure the command line can pass along these options, which usually just means if you uses npm, add an additional "--" at the end (e.g. `"npm run test --"`) if you haven't already in your script. It is recommended not to add the following options as they are managed by the extension: `--watch`, `--watchAll`, `--coverage` -##### rootPath +--- +#### rootPath If your project doesn't live in the root of your repository, you may want to customize the `jest.rootPath` setting to enlighten the extension as to where to look. For instance: `"jest.rootPath": "src/client-app"` will direct the extension to use the `src/client-app` folder as the root for Jest. -##### coverageFormatter +--- +#### coverageFormatter There are 2 formatters to choose from:
@@ -307,7 +326,10 @@ There are 2 formatters to choose from: _(Note, there is an known issue in vscode (microsoft/vscode#5923) that gutter decorators could interfere with debug breakpoints visibility. Therefore, you probably want to disable coverage before debugging or switch to DefaultFormatter)_ -##### coverageColors + +--- + +#### coverageColors Besides the formatter, user can also customize the color via `jest.coverageColors` to change color for 3 coverage categories: `"uncovered", "covered", or "partially-covered"`,
@@ -330,9 +352,116 @@ for example: ```
-##### autoRun +--- + +#### runMode + +The `runMode` controls test UX, determining when tests should run, and housing the common run-time toggles like coverage. + +**runMode type** +```ts +interface JestRunModeOptions { + runAllTestsOnStartup?: boolean; + coverage?: boolean; + revealOutput?: 'on-run' | 'on-exec-error' | 'on-demand'; + deferred?: boolean; +} +export type JestRunMode = JestRunModeOptions & ( + | { type: 'watch' } + | { type: 'on-demand' } + | { type: 'on-save'; testFileOnly?: boolean } +); +``` +- **JestRunMode**: Here's what each type does: + - **watch**: Automatically triggers tests using watchman. + - **on-demand**: Manually run tests through the UI. + - **on-save**: Triggers tests every time you save a test or source file. + +- **JestRunModeOptions**: Options applicable for all `runMode` types: + - **runAllTestsOnStartup**: Want to run all tests as soon as the extension starts? Use this. + - **coverage**: To get those coverage metrics, turn this on. + - **revealOutput**: Control when your test output window shows up: + - "on-run": Default behavior, pops up as tests begin. + - "on-exec-error": Shows up only if there's an execution glitch. + - "on-demand": Displays only when you ask it to via the UI. + - **deferred**: Usually, the extension sets things up before any test run, verifying the Jest env and discovering tests. This process is generally quick, but if you've got a hefty project or your setup isn't Jest-ready, this option helps: + - `true`: Suspend the initial setup. Most UI components remain active. If you toggle `runMode.deferred` or manually trigger a test run, the setup will resume, deferred option will be set to false, and the runMode will operate as usual. + - `false`: Default behavior, the setup process gets going before any test run. + +
+ + 🤔 defer vs. disable? + + 📌 Note: There's a distinction between the deferred mode and disabling the extension via "jest.enable: false". Disabling the extension will remove all test features for the given workspace-folder. In contrast, deferred just delays the setup but most UI features are still visible. + +
+ -AutoRun controls when **tests** should be executed automatically. +**Predefined RunMode** + +The following are the predefined `runMode` configurations for convenience. They are mapped to the specified `JestRunMode` type at run time. + +|Predefined runMode|Description|JestRunMode| +|:---:|---|---| +|"watch"| run tests by watchman | {type: "watch", revealOutput: "on-run"} | +|"on-save"| run tests when test or source files are saved|{type: "on-run", revealOutput: "on-run"}| +|"on-demand"|run tests on-demand through UI | {type: "on-demand", revealOutput: "on-run"} | +|"deferred"|defer test run and discovery until the first on-demand run | {type: "on-demand", revealOutput: "on-run", deferred: true } | + +**runMode Examples** +- Run jest with watch mode - the default runMode if none is specified. + ```json + "jest.runMode": "watch" + ``` +- Run jest tests via UI only. + ```json + "jest.runMode": "on-demand" + ``` +- Delay extension setup until the actual on-demand run. + ```json + "jest.runMode": "deferred" + ``` +- Run tests with coverage when test files are saved. + ```json + "jest.runMode": { + "type": "on-save", + "testFileOnly": true, + "coverage": true + } + ``` +- Delay extension setup until the actual (on-demand) run; after that, automatically run tests when test/src files are saved. + ```json + "jest.runMode": { + "type": "on-save", + "deferred": true + } + ``` +**runMode performance tradeoff** + + +Balancing performance, convenience and completeness is often challenging. The runMode offers a tool to fine-tune this equilibrium according to your preferences. + +runmode-tradeoff + +While the concepts of performance and automation are generally clear, "completeness" may require some elaboration: + +1. Test coverage might not be comprehensive since it only captures the tests that were executed. +2. If you modify the source or test code, potential failures in other tests may remain hidden until they are explicitly run. +3. Tests bearing dynamic names, like those using test.each with variables or template literals, won't be translated. As a result, they must be executed through higher-level constructs, such as describe blocks with static names or entire test suites. + + +**runMode migration** + +Starting from v6.0.2, if no runMode is defined in settings.json, the extension will automatically generate one using legacy settings (`autoRun`, `autoRevealOutput`, `showCoverageOnLoad`). To migrate, simply use the `"Jest: Save Current RunMode"` command from the command palette to update the setting, then remove the legacy settings. + +--- + +#### autoRun +
+ As of v6.0.2, runMode has superseded autoRun. For transition details, please refer to the runMode migration. +
+ +AutoRun controls when tests should be executed automatically. Performance and automation/completeness are often a trade-off. autoRun is the tool to fine-tune the balance, which is unique for every project and user. @@ -411,13 +540,18 @@ There are 2 ways to change autoRun: **Please note**, _even when the `autoRun` is "off", the extension will still perform the usual setup upon start-up, such as checking jest env and parsing test blocks, so users can run test blocks manually. To turn off the extension completely for the given workspace, you can use `jest.enable` setting instead._ -##### testExplorer + +--- + +#### testExplorer ```ts testExplorer = {showInlineError?: boolean} ``` - `showInlineError`: (optional) show vscode style inline error and error message viewer. Default is false. -##### shell +--- + +#### shell ```ts shell = string | LoginShell; @@ -435,7 +569,9 @@ Note the LoginShell is only applicable for non-windows platform and could cause _Note_: Since v5, if detected shell env issue, such as `node: command not found` or `npm: no such file or directory`, the extension will fallback to a login shell to ensure tests can run correctly. If will try to auto generate a login shell configuration based on the `jest.shell` setting, otherwise, it will use the default `bash` login-shell. Currently supported auto-fallback shells are `bash`, `zsh`, `fish`. -##### monitorLongRun +--- + +#### monitorLongRun ```ts monitorLongRun = number | 'off' ``` @@ -445,7 +581,14 @@ monitorLongRun = number | 'off' Default is `"jest.monitorLongRun":60000` (1 minute) -##### autoRevealOutput + +--- + +#### autoRevealOutput +
+ As of v6.0.2, runMode has superseded autoRevealOutput. For transition details, please refer to the runMode migration. +
+ ```ts autoRevealOutput = "on-run" | "on-exec-error" | "off" ``` @@ -453,7 +596,9 @@ autoRevealOutput = "on-run" | "on-exec-error" | "off" - `on-exec-error`: reveal test run output only when execution error (note, not test error) occurred. - `off`: no auto reveal test output. Note this could mask critical error, check status bar status for detail. -##### parserPluginOptions +--- + +#### parserPluginOptions ```ts parserPluginOptions = { decorators?: @@ -471,27 +616,22 @@ This extension uses babel to parse the test files. For decorators [plugin option "jest.parserPluginOptions": {"decorators": {"decoratorsBeforeExport": false}} ``` -##### useDashedArgs -If true, use dashed arguments for spawned jest processes. - -Please bear in mind that you might encounter compatibility issue with other tools/systems. For instance, we've identified an issue in react-script where `"watch-all"=false` (an argument the extension appended) isn't recognized (see [facebook/react-script#12801](https://github.com/facebook/create-react-app/issues/12801) for more details). Please report them if you encounter any. - -Also note this flag does not apply to debug config, which can be changed manually since the [debug config v2](#debug-config-v2) was introduced a while back. +--- -##### virtualFolders +#### virtualFolders Much like a vscode workspace folder, which manages a runtime environment for a specific folder, a virtualFolder manages a custom Jest runtime environment. Each virtualFolder can have its own resource-level [settings](#settings), such as `jestCommandLine` and `rootPath`. You can configure multiple virtual folders within any given vscode workspace folder using the `jest.virtualFolders` setting. Here are a few common use cases and examples: -1. Your project contains multiple jest configurations, such as unit test and integration test. While they run on the same set of source files, you want to run them separately and with different frequency: for instance, unit tests should always run automatically ("autoRun": "watch") while the integration tests should only run on-demand ("autoRun": "off"): +1. Your project contains multiple jest configurations, such as unit test and integration test. While they run on the same set of source files, you want to run them separately and with different frequency: for instance, unit tests should always run automatically ("runMode": "watch") while the integration tests should only run on-demand ("runMode": "on-demand"): ```json // settings.json for unit and integration test environments under the root folder: { "jest.virtualFolders": [ - {"name": "unit-tests", "jestCommandLine": "yarn test --config jest.unit.config.js", "autoRun": "watch"}, - {"name": "integration-tests", "jestCommandLine": "yarn test --config jest.integration.config.js", "autoRun": "off"} + {"name": "unit-tests", "jestCommandLine": "yarn test --config jest.unit.config.js", "runMode": "watch"}, + {"name": "integration-tests", "jestCommandLine": "yarn test --config jest.integration.config.js", "runMode": "on-demand"} ] } ``` @@ -510,24 +650,28 @@ You can configure multiple virtual folders within any given vscode workspace fol **Virtual Folders Inherit and Override Workspace Folder Settings** -Virtual folders inherit settings from the parent workspace but can override these settings as needed. For instance, you can set the autoRun option to "off" in the workspace, but enable it for a specific virtual folder. This flexibility allows for granular control over the testing environments within a single workspace folder. +Virtual folders inherit settings from the parent workspace but can override these settings as needed. For instance, you can set the runMode to "watch" in the workspace, but set it to "on-demand" for the virtual folders with many or expensive tests. This flexibility allows for granular control over the testing environments within a single workspace folder. ```json // settings.json in the "project" folder { ... - "jest.autoRun": "off", + "jest.runMode": "watch", "jest.jestCommandLine": "yarn test", "jest.virtualFolders": [ - {"name": "unit-tests", "autoRun": "watch"}, - {"name": "integration-tests", "jestCommandLine": "yarn test --config=jest.integration.config.js"} + {"name": "unit-tests"}, + {"name": "integration-tests", , "runMode": "on-demand", "jestCommandLine": "yarn test --config=jest.integration.config.js"} ] } ``` In this scenario, the "project" workspace will not run its own Jest environment but will instead spawn two separate Jest environments for "unit-tests" and "integration-tests". +
+ + 🤔 VirtualFolders vs. Multi-root Workspace? + **VirtualFolders vs. Multi-root Workspace** While virtual folders are primarily designed to address the gap of supporting multiple test configurations for the same set of source files, they are also capable of supporting monorepo projects. Some developers might prefer this approach over traditional multi-root workspaces due to the simplified and centralized settings over individual package settings. @@ -539,6 +683,10 @@ However, there are some key differences between virtual folders and multi-root w 4. You might receive additional prompts to select target folders when running commands for shared files. 5. virtualFolders only contains jest settings. If your project require non-jest settings from the package's own `.vscode/settings.json` (like in a multi-root workspace), then you are probably better off continue with multi-root workspace. +
+ +--- + ### Debug Config @@ -633,6 +781,7 @@ This extension contributes the following commands and can be accessed via [Comma |Jest: Run All Tests (Select Workspace)| run all tests for the selected workspace|multi-root workspace |Jest: Run All Tests in Current Workspace| run all tests for the current workspace based on the active editor| always |Jest: Toggle Coverage for Current Workspace| toggle coverage mode for the current workspace based on the active editor| always +|Jest: Save Current RunMode| update runMode in `settings.json` based on the current value| always (>= 6.0.2) |Jest: Setup Extension| start the setup tool|always| In addition, TestExplorer also exposed many handy commands, see the full list by searching for `testing` in [vscode keyboard shortcuts editor](https://code.visualstudio.com/docs/getstarted/keybindings#_keyboard-shortcuts-editor). One can assign/change keyboard shortcut to any of these commands, see [vscode Key Bindings](https://code.visualstudio.com/docs/getstarted/keybindings) for more details. @@ -660,34 +809,27 @@ Sorry you are having trouble with the extension. If your issue did not get resol - **jest command line issue**: such as you usually run `yarn test` but the extension uses the default `jest` instead. - Try configuring the [jest.jestCommandLine](#jestcommandline) to mimic how you run jest from the terminal, such as `yarn test` or `npm run test --`. The extension can auto-config common configurations like create react apps but not custom scripts like [CRACO](https://github.com/gsoft-inc/craco). - - or you can use the **"Run Setup Tool"** button in the error panel to resolve the configuration issue, see [Setup Tool](setup-wizard.md). + - **root path issue**: When jest test root path is different from the workspace folder. You can set [jest.rootPath](#rootPath) to the actual jest root. - **monorepo project issue**: you have a monorepo project but might not have been set up properly. - - short answer is try [Setup monorepo project](setup-wizard.md#setup-monorepo-project) tool. Or read more detail in [how to use the extension with monorepo projects](#how-to-use-the-extension-with-monorepo-projects). + - Please reference [how to use the extension with monorepo projects](#how-to-use-the-extension-with-monorepo-projects). -There could be other causes, such as jest test root path is different from the project's, which can be fixed by setting [jest.rootPath](#rootPath). Feel free to check out the [customization](#customization) section to manually adjust the extension if needed. +Feel free to try the [quick-fix chooser](#quick-fix-chooser), or check out the [customization](#customization) section to manually adjust the extension. + +A few known failure scenarios: +- PNP without node_modules nor a "test" script in package.json will need to set up jest.jestCommandLine explicitly. ### Performance issue? The extension should be a thin wrapper on top of the jest process, i.e., it shouldn't use much more resources than the jest process itself. -Having said that, we have definitely seen sluggish performance for some projects/users. The short answer is [try turning off autoRun in the explorer](#how-to-toggle-auto-run), which should usually show noticeable improvement. +Having said that, we have definitely seen sluggish performance for some projects/users. The short answer is [try changing runMode in the explorer](#how-to-change-runmode-for-the-current-session), which should usually show noticeable improvement. The long answer is a bit more complicated: - The jest/node/watchman might be slow due to code changes, your test setup, environment, etc. See [facebook/jest#11956](https://github.com/facebook/jest/issues/11956) for a glimpse of such examples. However, this issue should impact with or without this extension. There are many resources and tips online about optimizing jest performance; we will leave it at that. -- Depending on the degree of cross-dependency or your development habit (e.g., save frequently even before the code is complete), the autoRun system ( jest watchman "watch" or "on-save") might decide to run many more tests than you intended to. Imagine adding a single test could trigger 90% of all the tests in the project... yeah we have been there, and it's not fun. If that's you, try [toggling off autoRun in TestExplorer](#how-to-toggle-auto-run) and only trigger test-run when ready with the run button in the gutter or test tree. - - But keep in mind while performance is important, turning autoRun off or setting it to be less "complete" does come with a cost, such as incomplete coverage and missing-broken-tests-detection. Please read up on the [autoRun trade-off](#autorun) and experiment to find the one that works for you. +- Depending on the degree of cross-dependency or your development habit (e.g., save frequently even before the code is complete), the runMode system ( "watch" or "on-save") might decide to run many more tests than you intended to. Imagine adding a single test could trigger 90% of all the tests in the project... yeah we have been there, and it's not fun. If that's you, try [changing runMode to "on-demand"](#how-to-change-runmode-for-the-current-session) and only trigger test-run when ready with the run button in the gutter or test tree. + - But keep in mind while performance is important, turning runMode to be less "complete" does come with a cost, such as incomplete coverage and missing-broken-tests-detection. Please read up on the [runMode trade-off](#runmode-tradeoff) and experiment to find the one that works for you. - Never say never; it is possible that we did something stupid. :cold_sweat: Feel free to log an issue if your performance awe still needs to be resolved after you patiently read and tried the above. -
- -fine tune performance with autoRun demo - -https://user-images.githubusercontent.com/891093/199872543-4f37de90-1e56-4e0d-8387-9af591264e13.mov - -Every project and developer are different. Experiment and pick the autoRun setting that fits your style and preference! - -
- ### Intermittent errors for (npm/yarn/node) command not found during test run or debugging This should only happen in Linux or MacOS, and is due to vscode not able to fully initialize the shell env when it starts up (more details [here](https://code.visualstudio.com/docs/supporting/faq#_resolving-shell-environment-fails)). @@ -721,9 +863,9 @@ If none of the auto activation criteria is met, you can do the following to manu ### What to do with "Long Running Tests Warning" The extension monitor excessive test run with ["jest.monitorLongRun"](#monitorlongrun) setting. By default if any runs exceed 60 seconds, a warning message will be shown. -- If running the tests with the extension seems to be longer than running it from a terminal, chances are you can use ["jest.autoRun"](#autorun) to optimize it, for example: - - for process type "all-tests", you can turn off the all-tests from autoRun. - - for process type "watch-tests" or "watch-all-tests", you can maybe turn off watch mode and use "onSave" instead. +- If running the tests with the extension seems to be longer than running it from a terminal, chances are you can use ["jest.runMode"](#runmode) to optimize it, for example: + - for process type "all-tests", you can turn off the all-tests from `runMode`. + - for process type "watch-tests" or "watch-all-tests", you can maybe turn off watch mode and use "on-save" or "on-demand" instead. - If the tests are slow even from the terminal, i.e. without the extension, you will need to optimize your tests, feel free to check out [jest troubleshooting](https://jestjs.io/docs/troubleshooting) or other online articles. - If the run appeared to hang, i.e. the TestExplorer or statusBar showed test running when it is not. It might be related to this [jest issue](https://github.com/facebook/jest/issues/13187), which should be fixed after release `29.0.2`. If you believe your issue is different, please [file a new issue](https://github.com/jest-community/vscode-jest/issues) so we can take a look. diff --git a/icons/coverage-on-20.svg b/icons/coverage-on-20.svg new file mode 100644 index 000000000..92ca44b76 --- /dev/null +++ b/icons/coverage-on-20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/pause-on-20.svg b/icons/pause-on-20.svg new file mode 100644 index 000000000..9a3cdb309 --- /dev/null +++ b/icons/pause-on-20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/debug-screen-shot.png b/images/debug-screen-shot.png deleted file mode 100644 index 57289877c..000000000 Binary files a/images/debug-screen-shot.png and /dev/null differ diff --git a/images/jest-env-error.jpg b/images/jest-env-error.jpg deleted file mode 100644 index 2c292fc7d..000000000 Binary files a/images/jest-env-error.jpg and /dev/null differ diff --git a/images/pre-release.png b/images/pre-release.png deleted file mode 100644 index c9517716e..000000000 Binary files a/images/pre-release.png and /dev/null differ diff --git a/images/quick-fix-chooser.png b/images/quick-fix-chooser.png new file mode 100644 index 000000000..a755e57a9 Binary files /dev/null and b/images/quick-fix-chooser.png differ diff --git a/images/runmode-chooser.png b/images/runmode-chooser.png new file mode 100644 index 000000000..8085ae368 Binary files /dev/null and b/images/runmode-chooser.png differ diff --git a/images/runmode-tradeoff.png b/images/runmode-tradeoff.png new file mode 100644 index 000000000..62f1b9d5a Binary files /dev/null and b/images/runmode-tradeoff.png differ diff --git a/images/status-bar-manual.png b/images/status-bar-manual.png deleted file mode 100644 index ebd4e18c8..000000000 Binary files a/images/status-bar-manual.png and /dev/null differ diff --git a/images/status-bar-overview.png b/images/status-bar-overview.png new file mode 100644 index 000000000..b2c8854a6 Binary files /dev/null and b/images/status-bar-overview.png differ diff --git a/images/status-bar-save-all.png b/images/status-bar-save-all.png deleted file mode 100644 index 234745a8d..000000000 Binary files a/images/status-bar-save-all.png and /dev/null differ diff --git a/images/status-bar-save-test-unsync.png b/images/status-bar-save-test-unsync.png deleted file mode 100644 index de5cadc06..000000000 Binary files a/images/status-bar-save-test-unsync.png and /dev/null differ diff --git a/images/status-bar-save-test.png b/images/status-bar-save-test.png deleted file mode 100644 index e25dac0a0..000000000 Binary files a/images/status-bar-save-test.png and /dev/null differ diff --git a/images/status-bar-watch-coverage.png b/images/status-bar-watch-coverage.png deleted file mode 100644 index 59f084677..000000000 Binary files a/images/status-bar-watch-coverage.png and /dev/null differ diff --git a/images/status-bar-watch.png b/images/status-bar-watch.png deleted file mode 100644 index 8bd0073e0..000000000 Binary files a/images/status-bar-watch.png and /dev/null differ diff --git a/images/testExplorer-5.1.1.png b/images/testExplorer-5.1.1.png deleted file mode 100644 index 15137e590..000000000 Binary files a/images/testExplorer-5.1.1.png and /dev/null differ diff --git a/images/testExplorer-6.0.2.png b/images/testExplorer-6.0.2.png new file mode 100644 index 000000000..03eaf70d0 Binary files /dev/null and b/images/testExplorer-6.0.2.png differ diff --git a/images/testExplorer.png b/images/testExplorer.png deleted file mode 100644 index 0b2e7f649..000000000 Binary files a/images/testExplorer.png and /dev/null differ diff --git a/package.json b/package.json index ece8e4089..3dff3f4fd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vscode-jest", "displayName": "Jest", "description": "Use Facebook's Jest With Pleasure.", - "version": "6.0.1", + "version": "6.0.2", "publisher": "Orta", "engines": { "vscode": "^1.68.1" @@ -108,7 +108,9 @@ "description": "Show code coverage when extension starts (if collected)", "type": "boolean", "default": false, - "scope": "resource" + "scope": "resource", + "markdownDeprecationMessage": "**Deprecated**: Please use [runMode](https://github.com/jest-community/vscode-jest/blob/master/README.md#runmode) instead.", + "deprecationMessage": "Deprecated: Please use jest.runMode instead." }, "jest.coverageFormatter": { "description": "Coverage formatter to use", @@ -140,13 +142,15 @@ "scope": "window" }, "jest.autoRun": { - "markdownDescription": "Control when jest should run (changed) tests. It supports multiple models, such as fully automated, fully manual and onSave... See [AutoRun](https://github.com/jest-community/vscode-jest/blob/master/README.md#how-to-trigger-the-test-run) for details and examples", + "markdownDescription": "Control when jest should run (changed) tests. It supports multiple models, such as fully automated, onSave, and on-demand... See [AutoRun](https://github.com/jest-community/vscode-jest/blob/master/README.md#how-to-trigger-the-test-run) for details and examples", "type": [ "object", "string" ], "default": null, - "scope": "resource" + "scope": "resource", + "markdownDeprecationMessage": "**Deprecated**: Please use [runMode](https://github.com/jest-community/vscode-jest/blob/master/README.md#runmode) instead.", + "deprecationMessage": "Deprecated: Please use jest.runMode instead." }, "jest.testExplorer": { "markdownDescription": "Configure jest TestExplorer. See valid [formats](https://github.com/jest-community/vscode-jest/blob/master/README.md#testexplorer) or [how to use test explorer](https://github.com/jest-community/vscode-jest/blob/master/README.md#how-to-use-the-test-explorer) for more details", @@ -177,7 +181,9 @@ "auto show test output when execution error occurred", "disable auto show test output" ], - "scope": "resource" + "scope": "resource", + "markdownDeprecationMessage": "**Deprecated**: Please use [runMode](https://github.com/jest-community/vscode-jest/blob/master/README.md#runmode) instead.", + "deprecationMessage": "Deprecated: Please use jest.runMode instead." }, "jest.parserPluginOptions": { "markdownDescription": "Configure babel parser plugins. See valid [format](https://github.com/jest-community/vscode-jest/blob/master/README.md#parserpluginoptions)", @@ -192,9 +198,97 @@ "scope": "resource" }, "jest.virtualFolders": { - "markdownDescription": "Allows multiple jest run config for a given (physical) folder. See valid [format](TODO)", - "type": "array", - "default": null + "markdownDescription": "Allows multiple jest run config for a given (physical) folder. See valid [format](https://github.com/jest-community/vscode-jest#virtualfolders)", + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": "object" + } + }, + "jest.runMode": { + "markdownDescription": "Control when to run jest tests and present the results. See details in [runMode](https://github.com/jest-community/vscode-jest#runmode)", + "default": null, + "scope": "resource", + "oneOf": [ + { + "type": "string", + "enum": [ + "watch", + "on-demand", + "on-save", + "deferred" + ], + "markdownDescription": "A predefined Jest run mode. See details in [runMode](https://github.com/jest-community/vscode-jest#runmode)" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "watch", + "on-demand", + "on-save" + ], + "description": "Specifies the jest run mode type." + }, + "runAllTestsOnStartup": { + "type": "boolean", + "description": "Specifies whether to run all tests on startup." + }, + "coverage": { + "type": "boolean", + "description": "Specifies whether to collect and report coverage information." + }, + "revealOutput": { + "type": "string", + "enum": [ + "on-run", + "on-exec-error", + "on-demand" + ], + "description": "Determines when to reveal the test run output." + }, + "deferred": { + "type": "boolean", + "description": "Specifies whether the run mode is deferred." + } + }, + "required": [ + "type" + ], + "additionalProperties": true, + "if": { + "properties": { + "type": { + "const": "on-save" + } + } + }, + "then": { + "properties": { + "testFileOnly": { + "type": "boolean", + "description": "if true, will run tests only when saving test files." + } + }, + "additionalProperties": true + }, + "else": { + "not": { + "required": [ + "testFileOnly" + ] + }, + "errorMessage": "The property 'testFileOnly' should only be present when 'type' is 'on-save'.", + "additionalProperties": true + }, + "markdownDescription": "A detailed runMode configuration. See details in [runMode](https://github.com/jest-community/vscode-jest#runmode)" + } + ] } } }, @@ -227,6 +321,10 @@ "command": "io.orta.jest.workspace.run-all-tests", "title": "Jest: Run All Tests (Select Workspace)" }, + { + "command": "io.orta.jest.workspace.save-run-mode", + "title": "Jest: Save Current RunMode" + }, { "command": "io.orta.jest.run-all-tests", "title": "Jest: Run All Tests" @@ -248,24 +346,9 @@ "title": "Jest: Setup Extension" }, { - "command": "io.orta.jest.test-item.auto-run.toggle-on", - "title": "Toggle AutoRun On", - "icon": "$(sync)" - }, - { - "command": "io.orta.jest.test-item.auto-run.toggle-off", - "title": "Toggle AutoRun Off", - "icon": "$(sync-ignored)" - }, - { - "command": "io.orta.jest.test-item.coverage.toggle-off", - "title": "Toggle Coverage Off", - "icon": "$(circle-slash)" - }, - { - "command": "io.orta.jest.test-item.coverage.toggle-on", - "title": "Toggle Coverage On", - "icon": "$(color-mode)" + "command": "io.orta.jest.test-item.run-mode.change", + "title": "Change RunMode", + "icon": "$(gear)" }, { "command": "io.orta.jest.test-item.reveal-output", @@ -314,19 +397,7 @@ "when": "jest.never" }, { - "command": "io.orta.jest.test-item.auto-run.toggle-on", - "when": "jest.never" - }, - { - "command": "io.orta.jest.test-item.auto-run.toggle-off", - "when": "jest.never" - }, - { - "command": "io.orta.jest.test-item.coverage.toggle-on", - "when": "jest.never" - }, - { - "command": "io.orta.jest.test-item.coverage.toggle-off", + "command": "io.orta.jest.test-item.run-mode.change", "when": "jest.never" }, { @@ -351,28 +422,13 @@ ], "testing/item/context": [ { - "command": "io.orta.jest.test-item.auto-run.toggle-off", - "group": "inline@1", - "when": "testId in jest.autoRun.on" - }, - { - "command": "io.orta.jest.test-item.auto-run.toggle-on", + "command": "io.orta.jest.test-item.run-mode.change", "group": "inline@1", - "when": "testId in jest.autoRun.off" - }, - { - "command": "io.orta.jest.test-item.coverage.toggle-off", - "group": "inline@2", - "when": "testId in jest.coverage.on" - }, - { - "command": "io.orta.jest.test-item.coverage.toggle-on", - "group": "inline@2", - "when": "testId in jest.coverage.off" + "when": "testId in jest.runMode" }, { "command": "io.orta.jest.test-item.reveal-output", - "group": "inline@3", + "group": "inline@2", "when": "testId in jest.workspaceRoot" }, { @@ -520,7 +576,8 @@ "test": "jest", "watch-test": "yarn test -- --watch", "tsc": "tsc --noEmit", - "update-vscode-type": "npx vscode-dts main; mv ./vscode.d.ts ./typings" + "update-vscode-type": "npx vscode-dts main; mv ./vscode.d.ts ./typings", + "compare-coverage": "ts-node scripts/compare-coverage.ts " }, "dependencies": { "istanbul-lib-coverage": "^3.2.0", @@ -528,6 +585,7 @@ "jest-editor-support": "^31.1.1" }, "devDependencies": { + "@types/fs-extra": "^11.0.2", "@types/istanbul-lib-coverage": "^2.0.4", "@types/istanbul-lib-source-maps": "^4.0.1", "@types/jest": "^29.2.5", @@ -541,6 +599,7 @@ "eslint-plugin-jsdoc": "^39.6.4", "eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prettier": "^4.2.1", + "fs-extra": "^11.1.1", "jest": "^29.3.1", "jest-snapshot": "^27.2.0", "prettier": "^2.8.2", @@ -548,6 +607,7 @@ "rimraf": "^3.0.2", "ts-jest": "^29.0.3", "ts-loader": "^9.4.2", + "ts-node": "^10.9.1", "typescript": "^4.9.4", "webpack": "^5.75.0", "webpack-cli": "^5.0.1" diff --git a/scripts/compare-coverage.ts b/scripts/compare-coverage.ts new file mode 100644 index 000000000..1e4d17dc6 --- /dev/null +++ b/scripts/compare-coverage.ts @@ -0,0 +1,167 @@ +import * as fs from 'fs-extra'; + +interface JestCoverageData { + statementMap: Record; + s: Record; +} + +interface ComparisonData { + metrics: { + [metric: string]: { + oldCoverage: number; + newCoverage: number; + diff: number; + }; + }; + lostLines: number[]; +} + +async function readCoverageData(directory: string): Promise> { + const filePath = `${directory}/coverage-final.json`; + if (!fs.existsSync(filePath)) { + console.error(`File ${filePath} does not exist.`); + process.exit(1); + } + const rawData = await fs.readJSON(filePath); + return rawData; +} + +function findLostCoverage(oldData: JestCoverageData, newData: JestCoverageData): number[] { + const lostLines: number[] = []; + + for (const [id, oldCount] of Object.entries(oldData.s)) { + const newCount = newData.s[id]; + if (oldCount > 0 && (newCount === undefined || newCount === 0)) { + lostLines.push(oldData.statementMap[id].start.line); + } + } + + return lostLines; +} + +function generateHTMLReport( + comparisonData: Record, + totalOldCoverage: number, + totalNewCoverage: number +): void { + let html = 'Coverage Comparison'; + // Add some CSS styles for padding and selective centering + html += ''; + html += ''; + html += '

Coverage Comparison Report

'; + + // Total coverage change summary + const totalDiff = totalNewCoverage - totalOldCoverage; + const summaryColor = totalDiff < 0 ? 'red' : totalDiff > 0 ? 'green' : 'black'; + html += '

Summary

'; + html += + ''; + html += ``; + html += '
MetricOld CoverageNew CoverageChange
Total Coverage${totalOldCoverage.toFixed( + 2 + )}%${totalNewCoverage.toFixed( + 2 + )}%${totalDiff.toFixed(2)}%
'; + + // Table for all files + html += '

All Files

'; + html += + ''; + + for (const [filePath, data] of Object.entries(comparisonData) as [string, ComparisonData][]) { + const diff = data.metrics['Statement Coverage'].diff; + const color = diff < 0 ? 'red' : diff > 0 ? 'green' : 'black'; + html += ``; + } + + html += '
FileOld CoverageNew CoverageDiff
${filePath}${data.metrics[ + 'Statement Coverage' + ].oldCoverage.toFixed(2)}%${data.metrics[ + 'Statement Coverage' + ].newCoverage.toFixed(2)}%${data.metrics[ + 'Statement Coverage' + ].diff.toFixed(2)}%
'; + + // Table for files that lost coverage + html += '

Files That Lost Overall Coverage

'; + html += ''; + + for (const [filePath, data] of Object.entries(comparisonData) as [string, ComparisonData][]) { + if (data.metrics['Statement Coverage'].diff < 0) { + // Only include files with overall loss in coverage + if (data.lostLines.length > 0) { + html += ``; + } + } + } + + html += '
FileLost Lines
${filePath}${data.lostLines.join(', ')}
'; + html += ''; + + const fName = 'coverage_comparison_report.html'; + fs.writeFileSync(fName, html); + console.log(`Report generated: ${fName}`); +} + +async function main(): Promise { + const oldDir = process.argv[2]; + const newDir = process.argv[3]; + + if (!oldDir || !newDir) { + console.log('Usage: ts-node script.ts '); + return; + } + + const oldData = await readCoverageData(oldDir); + const newData = await readCoverageData(newDir); + + const comparisonData: Record = {}; + + let totalOldCoverage = 0; + let totalNewCoverage = 0; + let fileCount = 0; + + for (const [filePath, oldCoverage] of Object.entries(oldData)) { + const newCoverage = newData[filePath]; + if (!newCoverage) { + console.log(`File ${filePath} is missing in the new coverage data.`); + continue; + } + + const lostLines = findLostCoverage(oldCoverage, newCoverage); + + const oldStatementCoverage = + (Object.values(oldCoverage.s).filter((v) => v > 0).length / + Object.keys(oldCoverage.s).length) * + 100; + const newStatementCoverage = + (Object.values(newCoverage.s).filter((v) => v > 0).length / + Object.keys(newCoverage.s).length) * + 100; + const statementDiff = newStatementCoverage - oldStatementCoverage; + + comparisonData[filePath] = { + metrics: { + 'Statement Coverage': { + oldCoverage: oldStatementCoverage, + newCoverage: newStatementCoverage, + diff: statementDiff, + }, + }, + lostLines, + }; + + totalOldCoverage += oldStatementCoverage; + totalNewCoverage += newStatementCoverage; + fileCount++; + } + + totalOldCoverage /= fileCount; + totalNewCoverage /= fileCount; + + generateHTMLReport(comparisonData, totalOldCoverage, totalNewCoverage); +} + +main().catch((error) => { + console.error('An error occurred:', error); +}); diff --git a/scripts/orphan-images.sh b/scripts/orphan-images.sh new file mode 100755 index 000000000..b8aafcefc --- /dev/null +++ b/scripts/orphan-images.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +function print_usage() { + echo "Usage: $0 [OPTIONS]" + echo "Find and optionally delete unused image files." + echo + echo "Options:" + echo " -d Delete the unused image files." + echo " -h Display this help message." + exit 1 +} + +# If the -h argument is provided, display usage +if [[ $1 == "-h" ]]; then + print_usage +fi + +# Directory where the images reside +IMAGES_DIR="images" + +# Directories to exclude from the search +EXCLUDE_DIRS=("images" ".git" ".node_modules" "out" ".vscode") # Add any other directories you wish to exclude + +# Construct the grep exclude pattern for directories +EXCLUDE_DIR_PATTERN="" +for dir in "${EXCLUDE_DIRS[@]}"; do + EXCLUDE_DIR_PATTERN="$EXCLUDE_DIR_PATTERN --exclude-dir=$dir" +done +echo EXCLUDE_DIR_PATTERN: $EXCLUDE_DIR_PATTERN + +# File types to include in the search +INCLUDE_FILES=("*.ts" "*.json" "*.md") # Specify the file types you want to search within + +# Construct the grep include pattern for files +INCLUDE_FILE_PATTERN="" +for file in "${INCLUDE_FILES[@]}"; do + INCLUDE_FILE_PATTERN="$INCLUDE_FILE_PATTERN --include=$file" +done +echo INCLUDE_FILE_PATTERN: $INCLUDE_FILE_PATTERN + +# Temporary file for storing results +TEMP_FILE="unused_images.txt" + +# Empty the temp file in case it already exists +> $TEMP_FILE + +# Counter for unused images +UNUSED_COUNT=0 + +RED='\033[31m' +NC='\033[0m' # No Color + +# Iterate over all image files +while read -r img; do + # Search for the image file in the specified file types and excluding the specified directories + grep -rl "$img" $EXCLUDE_DIR_PATTERN $INCLUDE_FILE_PATTERN . > /dev/null + + # If grep's exit status is non-zero, then the image is not referenced anywhere + if [ $? -ne 0 ]; then + echo "$img" >> $TEMP_FILE + # Increment the counter + ((UNUSED_COUNT++)) + echo -e "${RED}$img => not used ($UNUSED_COUNT) ${NC}" + else + echo -e "$img => used" + fi +done < <(find $IMAGES_DIR -type f \( -iname \*.jpg -o -iname \*.jpeg -o -iname \*.png -o -iname \*.gif \)) + +echo "-------------------------" + +# Display the results +if [ -s $TEMP_FILE ]; then + echo "$UNUSED_COUNT Unused images:" + cat $TEMP_FILE + echo "-------------------------" + + # If the -d argument is provided, delete the files + if [[ $1 == "-d" ]]; then + echo "Deleting unused images..." + while IFS= read -r img; do + rm "$img" + done < $TEMP_FILE + echo "Unused images deleted." + fi +else + echo "No unused images found." +fi + +# Clean up the temp file +rm $TEMP_FILE \ No newline at end of file diff --git a/src/Coverage/CoverageOverlay.ts b/src/Coverage/CoverageOverlay.ts index dec1de012..06126b38e 100644 --- a/src/Coverage/CoverageOverlay.ts +++ b/src/Coverage/CoverageOverlay.ts @@ -38,21 +38,11 @@ export class CoverageOverlay { return this._enabled; } - set enabled(value: boolean) { - this._enabled = value; - this.updateVisibleEditors(); - } - /** give formatter opportunity to dispose the decorators */ dispose(): void { this.formatter.dispose(); } - toggleVisibility(): void { - this._enabled = !this._enabled; - this.updateVisibleEditors(); - } - updateVisibleEditors(): void { for (const editor of vscode.window.visibleTextEditors) { this.update(editor); diff --git a/src/JestExt/auto-run.ts b/src/JestExt/auto-run.ts deleted file mode 100644 index 3eeef4038..000000000 --- a/src/JestExt/auto-run.ts +++ /dev/null @@ -1,100 +0,0 @@ -import * as vscode from 'vscode'; -import { - JestExtAutoRunConfig, - JestExtAutoRunSetting, - JestExtAutoRunShortHand, - OnSaveFileType, - OnStartupType, -} from '../Settings'; -import { AutoRunMode } from '../StatusBar'; - -export class AutoRun { - private useOnConfig: boolean; - private readonly onConfig: JestExtAutoRunConfig; - private readonly offConfig: JestExtAutoRunConfig; - - constructor(setting: JestExtAutoRunSetting | null | undefined) { - let config: JestExtAutoRunConfig; - if (!setting) { - config = this.toAutoRun('default'); - } else if (typeof setting === 'string') { - config = this.toAutoRun(setting); - } else { - config = setting; - } - if (this.isConfigOff(config)) { - this.offConfig = config; - this.useOnConfig = false; - // different from "default" (which is "watch") ? If user specifically set "off" - // and want to turn on at runtime, we choose the one setting closest (in turns of trade-off metric) - // to the original setting. - this.onConfig = this.toAutoRun('on-save'); - } else { - this.onConfig = config; - this.useOnConfig = true; - this.offConfig = this.toAutoRun('off'); - } - } - - public get config(): JestExtAutoRunConfig { - return this.useOnConfig ? this.onConfig : this.offConfig; - } - public get isOff(): boolean { - return this.isConfigOff(this.config); - } - private isConfigOff(config: JestExtAutoRunConfig): boolean { - return config.watch === false && config.onSave == null && config.onStartup == null; - } - public get isWatch(): boolean { - return this.config.watch === true; - } - public get onSave(): OnSaveFileType | undefined { - return this.config.watch === false ? this.config.onSave : undefined; - } - public get onStartup(): OnStartupType | undefined { - return this.config.onStartup; - } - - public get mode(): AutoRunMode { - return this.autoRunMode(); - } - public toggle(): void { - this.useOnConfig = !this.useOnConfig; - } - - private autoRunMode(): AutoRunMode { - if (this.config.watch === false && !this.config.onSave && !this.config.onStartup) { - return 'auto-run-off'; - } - if (this.config.watch === true) { - return 'auto-run-watch'; - } - if (this.config.onSave === 'test-src-file') { - return 'auto-run-on-save'; - } - if (this.config.onSave === 'test-file') { - return 'auto-run-on-save-test'; - } - return 'auto-run-off'; - } - - private toAutoRun(shortHand: JestExtAutoRunShortHand): JestExtAutoRunConfig { - switch (shortHand) { - case 'legacy': - return { watch: true, onStartup: ['all-tests'] }; - case 'default': - case 'watch': - return { watch: true }; - case 'off': - return { watch: false }; - case 'on-save': - return { watch: false, onSave: 'test-src-file' }; - default: { - const message = `invalid autoRun setting "${shortHand}". Will use default setting instead`; - console.error(message); - vscode.window.showErrorMessage(message); - return this.toAutoRun('default'); - } - } - } -} diff --git a/src/JestExt/core.ts b/src/JestExt/core.ts index ae628fbc4..1fe27aeed 100644 --- a/src/JestExt/core.ts +++ b/src/JestExt/core.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { JestTotalResults } from 'jest-editor-support'; -import { statusBar, StatusBar, Mode, StatusBarUpdate, SBTestStats } from '../StatusBar'; +import { statusBar, StatusBar, StatusBarUpdate, SBTestStats } from '../StatusBar'; import { TestResultProvider, resultsWithLowerCaseWindowsDriveLetters, @@ -18,7 +18,7 @@ import { import { CoverageMapProvider, CoverageCodeLensProvider } from '../Coverage'; import { updateDiagnostics, updateCurrentDiagnostics, resetDiagnostics } from '../diagnostics'; import { DebugConfigurationProvider } from '../DebugConfigurationProvider'; -import { TestStats } from '../types'; +import { TestExplorerRunRequest, TestStats } from '../types'; import { CoverageOverlay } from '../Coverage/CoverageOverlay'; import { resultsWithoutAnsiEscapeSequence } from '../TestResults/TestResult'; import { CoverageMapData } from 'istanbul-lib-coverage'; @@ -31,7 +31,6 @@ import { JestRunEvent, DebugTestIdentifier, } from './types'; -import * as messaging from '../messaging'; import { extensionName, SupportedLanguageIds } from '../appGlobals'; import { createJestExtContext, getExtensionResourceSettings, prefixWorkspace } from './helper'; import { PluginResourceSettings } from '../Settings'; @@ -39,24 +38,16 @@ import { WizardTaskId } from '../setup-wizard'; import { ItemCommand, JestExtExplorerContext } from '../test-provider/types'; import { JestTestProvider } from '../test-provider'; import { JestProcessInfo } from '../JestProcessManagement'; -import { addFolderToDisabledWorkspaceFolders } from '../extension-manager'; -import { MessageAction } from '../messaging'; import { getExitErrorDef } from '../errors'; import { WorkspaceManager, isInFolder } from '../workspace-manager'; import { ansiEsc, JestOutputTerminal } from './output-terminal'; +import { QuickFixActionType } from '../quick-fix'; +import { executableTerminalLinkProvider } from '../terminal-link-provider'; interface RunTestPickItem extends vscode.QuickPickItem { id: DebugTestIdentifier; } -type MessageActionType = - | 'help' - | 'wizard' - | 'disable-folder' - | 'help-long-run' - | 'setup-cmdline' - | 'setup-monorepo'; - interface JestCommandSettings { rootPath: string; jestCommandLine: string; @@ -74,8 +65,6 @@ export class JestExt { // The ability to show fails in the problems section private failDiagnostics: vscode.DiagnosticCollection; - // We have to keep track of our inline assert fails to remove later - private processSession: ProcessSession; private vscodeContext: vscode.ExtensionContext; @@ -98,9 +87,9 @@ export class JestExt { coverageCodeLensProvider: CoverageCodeLensProvider ) { this.vscodeContext = vscodeContext; - const pluginSettings = this.getExtensionResourceSettings(workspaceFolder); - this.output = new JestOutputTerminal(workspaceFolder.name); + + const pluginSettings = this.getExtensionResourceSettings(workspaceFolder); this.updateOutputSetting(pluginSettings); this.extContext = createJestExtContext(workspaceFolder, pluginSettings, this.output); @@ -116,7 +105,7 @@ export class JestExt { this.coverageOverlay = new CoverageOverlay( vscodeContext, this.coverageMapProvider, - pluginSettings.showCoverageOnLoad, + pluginSettings.runMode.config.coverage, pluginSettings.coverageFormatter, pluginSettings.coverageColors ); @@ -162,10 +151,13 @@ export class JestExt { workspaceFolder: vscode.WorkspaceFolder ): PluginResourceSettings { const pluginSettings = getExtensionResourceSettings(workspaceFolder); - if (pluginSettings.enable === false) { throw new Error(`Jest is disabled for workspace ${workspaceFolder.name}`); } + this.output.write( + `RunMode: ${JSON.stringify(pluginSettings.runMode.config, undefined, 4)}`, + 'info' + ); return pluginSettings; } @@ -182,27 +174,16 @@ export class JestExt { debugTests: this.debugTests, }; } - private setupWizardAction(taskId?: WizardTaskId): messaging.MessageAction { + + public setupExtensionForFolder(args?: { taskId: WizardTaskId }): Thenable { const command = `${extensionName}.setup-extension`; - return { - title: 'Fix', - action: (): unknown => - vscode.commands.executeCommand(command, { - workspace: this.extContext.workspace, - taskId, - verbose: this.extContext.settings.debugMode, - }), - }; + return vscode.commands.executeCommand(command, { + workspace: this.extContext.workspace, + taskId: args?.taskId, + verbose: this.extContext.settings.debugMode, + }); } - private setupIgnoreAction(): messaging.MessageAction { - return { - title: 'Ignore Folder', - action: (): void => { - addFolderToDisabledWorkspaceFolders(this.extContext.workspace.name); - }, - }; - } private longRunMessage(event: Extract): string { const messages = [`Long Running Tests Warning: Jest process "${event.process.request.type}"`]; if (event.numTotalTestSuites != null) { @@ -213,17 +194,24 @@ export class JestExt { return messages.join(' '); } + private enableOutputOnRun(): void { + if ( + !this.extContext.settings.runMode.config.revealOutput || + this.extContext.settings.runMode.config.revealOutput === 'on-run' + ) { + this.output.enable(); + } + } private setupRunEvents(events: JestSessionEvents): void { events.onRunEvent.event((event: JestRunEvent) => { // only process the test running event if (event.process.request.type === 'not-test') { return; } + this.enableOutputOnRun(); + switch (event.type) { case 'start': { - if (this.extContext.settings.autoRevealOutput === 'on-run') { - this.output.reveal(); - } this.updateStatusBar({ state: 'running' }); break; } @@ -235,10 +223,15 @@ export class JestExt { case 'exit': if (event.error) { this.updateStatusBar({ state: 'exec-error' }); - messaging.systemErrorMessage( - prefixWorkspace(this.extContext, event.error), - ...this.buildMessageActions(['wizard', 'disable-folder', 'help']) - ); + if (!event.process.userData?.errorReported) { + this.outputActionMessages( + `Jest process exited unexpectedly: ${event.error}`, + ['wizard', 'defer', 'disable-folder', 'help'], + true, + event.error + ); + event.process.userData = { ...(event.process.userData ?? {}), errorReported: true }; + } } else { this.updateStatusBar({ state: 'done' }); } @@ -250,43 +243,30 @@ export class JestExt { break; } case 'long-run': { - const msg = prefixWorkspace(this.extContext, this.longRunMessage(event)); - messaging.systemWarningMessage(msg, ...this.buildMessageActions(['help-long-run'])); - this.logging('warn', msg); + this.outputActionMessages(this.longRunMessage(event), ['help-long-run'], false); break; } } }); } - private buildMessageActions = (types: MessageActionType[]): MessageAction[] => { - const actions: MessageAction[] = []; - for (const t of types) { - switch (t) { - case 'help': - actions.push(messaging.showTroubleshootingAction); - break; - case 'wizard': - actions.push(this.setupWizardAction()); - break; - case 'setup-cmdline': - actions.push(this.setupWizardAction('cmdLine')); - break; - case 'setup-monorepo': - actions.push(this.setupWizardAction('monorepo')); - break; - case 'disable-folder': - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 1) { - actions.push(this.setupIgnoreAction()); - } - break; - case 'help-long-run': - actions.push(messaging.showLongRunTroubleshootingAction); - break; - } - } - return actions; + private outputActionMessages = ( + errorMessage: string, + actionTypes: QuickFixActionType[], + isError: boolean, + extra?: unknown + ): void => { + const msg = prefixWorkspace(this.extContext, errorMessage); + this.logging(isError ? 'error' : 'warn', `${msg}:`, extra); + this.output.write(errorMessage, isError ? 'error' : 'new-line'); + const quickFixLink = executableTerminalLinkProvider.executableLink( + this.extContext.workspace.name, + `${extensionName}.with-workspace.show-quick-fix`, + actionTypes + ); + this.output.write(`Open Quick Fix: \u2192 ${quickFixLink}`, 'info'); }; + private createProcessSession(): ProcessSession { const sessionContext = { ...this.extContext, @@ -307,6 +287,17 @@ export class JestExt { */ public async startSession(newSession = false): Promise { try { + if (this.extContext.settings.runMode.config.deferred) { + // in deferred mode, we only start the test provider and nothing else + this.testProvider?.dispose(); + this.testProvider = new JestTestProvider(this.getExtExplorerContext()); + this.resetStatusBar(); + + vscode.window.visibleTextEditors.forEach((editor) => { + this.triggerUpdateActiveEditor(editor); + }); + return; + } const readyState = await this.validateJestCommandLine(); if (readyState !== 'pass') { return; @@ -328,19 +319,20 @@ export class JestExt { this.events.onTestSessionStarted.fire({ ...this.extContext, session: this.processSession }); - this.updateTestFileList(); + await this.updateTestFileList(); - if (vscode.window.activeTextEditor) { - this.triggerUpdateActiveEditor(vscode.window.activeTextEditor); - } + // update visible editors that belong to this folder + vscode.window.visibleTextEditors.forEach((editor) => { + this.triggerUpdateActiveEditor(editor); + }); } catch (e) { - const msg = prefixWorkspace(this.extContext, 'Failed to start jest session'); - this.logging('error', `${msg}:`, e); - this.extContext.output.write('Failed to start jest session', 'error'); - messaging.systemErrorMessage( - `${msg}...`, - ...this.buildMessageActions(['wizard', 'disable-folder', 'help']) + this.outputActionMessages( + `Failed to start jest session: ${e}`, + ['wizard', 'defer', 'disable-folder', 'help'], + true, + e ); + this.updateStatusBar({ state: 'exec-error' }); } } @@ -355,10 +347,13 @@ export class JestExt { this.updateStatusBar({ state: 'stopped' }); } catch (e) { - const msg = prefixWorkspace(this.extContext, 'Failed to stop jest session'); - this.logging('error', `${msg}:`, e); - this.extContext.output.write('Failed to stop jest session', 'error'); - messaging.systemErrorMessage('${msg}...', ...this.buildMessageActions(['help'])); + this.outputActionMessages( + `Failed to stop jest session: ${e}`, + ['defer', 'disable-folder', 'help'], + true, + e + ); + this.updateStatusBar({ state: 'exec-error' }); } } @@ -400,7 +395,8 @@ export class JestExt { } private updateOutputSetting(settings: PluginResourceSettings): void { - this.output.revealOnError = settings.autoRevealOutput !== 'off'; + this.output.revealOnError = + !settings.runMode.config.deferred && settings.runMode.config.revealOutput === 'on-exec-error'; this.output.close(); } private testResultProviderOptions(settings: PluginResourceSettings): TestResultProviderOptions { @@ -416,7 +412,11 @@ export class JestExt { newSettings ?? this.getExtensionResourceSettings(this.extContext.workspace); // output - if (this.extContext.settings.autoRevealOutput !== updatedSettings.autoRevealOutput) { + if ( + this.extContext.settings.runMode.config.revealOutput !== + updatedSettings.runMode.config.revealOutput || + this.extContext.settings.runMode.config.deferred !== updatedSettings.runMode.config.deferred + ) { this.updateOutputSetting(updatedSettings); } @@ -424,14 +424,11 @@ export class JestExt { this.testResultProvider.options = this.testResultProviderOptions(updatedSettings); // coverage - const showCoverage = this.coverageOverlay.enabled ?? updatedSettings.showCoverageOnLoad; - updatedSettings.showCoverageOnLoad = showCoverage; - this.coverageOverlay.dispose(); this.coverageOverlay = new CoverageOverlay( this.vscodeContext, this.coverageMapProvider, - updatedSettings.showCoverageOnLoad, + updatedSettings.runMode.config.coverage, updatedSettings.coverageFormatter, updatedSettings.coverageColors ); @@ -517,7 +514,7 @@ export class JestExt { } let msg = 'Not able to auto detect a valid jest command'; - let actionType: MessageActionType = 'setup-cmdline'; + let actionType: QuickFixActionType = 'setup-cmdline'; switch (result.validSettings.length) { case 1: @@ -544,11 +541,7 @@ export class JestExt { } } - messaging.systemErrorMessage( - prefixWorkspace(this.extContext, msg), - ...this.buildMessageActions([actionType, 'disable-folder', 'help']) - ); - this.extContext.output.write(`Abort jest session: ${msg}`, 'error'); + this.outputActionMessages(msg, [actionType, 'defer', 'disable-folder', 'help'], true); this.updateStatusBar({ state: 'exec-error' }); return 'fail'; } @@ -658,7 +651,10 @@ export class JestExt { } await vscode.debug.startDebugging(this.extContext.workspace, debugConfig); }; - public runAllTests(editor?: vscode.TextEditor): void { + public async runAllTests(editor?: vscode.TextEditor): Promise { + this.enableOutputOnRun(); + await this.exitDeferMode(); + if (!editor) { if (this.processSession.scheduleProcess({ type: 'all-tests' })) { this.dirtyFiles.clear(); @@ -715,21 +711,24 @@ export class JestExt { } private handleOnSaveRun(document: vscode.TextDocument): void { - if (!this.isSupportedDocument(document) || this.extContext.settings.autoRun.isWatch) { + if ( + !this.isSupportedDocument(document) || + this.extContext.settings.runMode.config.deferred || + this.extContext.settings.runMode.config.type !== 'on-save' + ) { return; } const isTestFile = this.testResultProvider.isTestFile(document.fileName); - if ( - this.extContext.settings.autoRun.onSave && - (this.extContext.settings.autoRun.onSave === 'test-src-file' || isTestFile !== 'no') - ) { + + if (isTestFile === 'no' && this.extContext.settings.runMode.config.testFileOnly) { + // not a test file and configured not to re-run test for non-test files => mark the workspace dirty + this.dirtyFiles.add(document.fileName); + } else { this.processSession.scheduleProcess({ type: 'by-file', testFileName: document.fileName, notTestFile: isTestFile !== 'yes', }); - } else { - this.dirtyFiles.add(document.fileName); } } @@ -786,21 +785,26 @@ export class JestExt { this.refreshDocumentChange(document); } - private updateTestFileList(): void { - this.processSession.scheduleProcess({ - type: 'list-test-files', - onResult: (files, error, exitCode) => { - this.setTestFiles(files); - this.logging('debug', `found ${files?.length} testFiles`); - if (error) { - const msg = - 'failed to retrieve test file list. TestExplorer might show incomplete test items'; - this.extContext.output.write(error, 'new-line'); - const errorType = getExitErrorDef(exitCode) ?? 'error'; - this.extContext.output.write(msg, errorType); - this.logging('error', msg, error); - } - }, + private async updateTestFileList(): Promise { + return new Promise((resolve, reject) => { + this.processSession.scheduleProcess({ + type: 'list-test-files', + onResult: (files, error, exitCode) => { + this.setTestFiles(files); + this.logging('debug', `found ${files?.length} testFiles`); + if (error) { + const msg = + 'failed to retrieve test file list. TestExplorer might show incomplete test items'; + this.extContext.output.write(error, 'new-line'); + const errorType = getExitErrorDef(exitCode) ?? 'error'; + this.extContext.output.write(msg, errorType); + this.logging('error', msg, error); + reject(error); + } else { + resolve(); + } + }, + }); }); } @@ -814,20 +818,57 @@ export class JestExt { this.updateTestFileList(); } - toggleCoverageOverlay(): Promise { - this.coverageOverlay.toggleVisibility(); - - // restart jest since coverage condition has changed + toggleCoverage(): Promise { + this.extContext.settings.runMode.toggleCoverage(); return this.triggerUpdateSettings(this.extContext.settings); } - toggleAutoRun(): Promise { - this.extContext.settings.autoRun.toggle(); + // exit defer runMode + async exitDeferMode(trigger?: TestExplorerRunRequest): Promise { + if (trigger) { + this.enableOutputOnRun(); + } + if (this.extContext.settings.runMode.config.deferred) { + this.extContext.settings.runMode.exitDeferMode(); + this.extContext.output.write('exit defer mode', 'new-line'); + await this.triggerUpdateSettings(this.extContext.settings); + if (trigger && this.testProvider) { + try { + await this.testProvider.runTests(trigger.request, trigger.token, true); + } catch (e) { + this.logging('error', 'failed to resume runs prior to defer mode', e); + this.extContext.output.write( + 'failed to resume runs prior to defer mode, you might need to trigger the run again', + 'error' + ); + } + } + } + } - // restart jest since coverage condition has changed - return this.triggerUpdateSettings(this.extContext.settings); + async saveRunMode(): Promise { + try { + await this.extContext.settings.runMode.save(this.extContext.workspace); + } catch (e) { + this.logging('error', 'failed to save runMode', e); + this.extContext.output.write( + 'failed to save the runMode settings. ${e}. \r\nPlease report this error.', + 'error' + ); + } + } + + // this method is invoked by the TestExplorer UI + async changeRunMode(): Promise { + const runMode = await this.extContext.settings.runMode.quickSwitch(this.vscodeContext); + if (runMode) { + const newSettings = { ...this.extContext.settings, runMode }; + return this.triggerUpdateSettings(newSettings); + } } - runItemCommand(testItem: vscode.TestItem, itemCommand: ItemCommand): void { - this.testProvider?.runItemCommand(testItem, itemCommand); + async runItemCommand(testItem: vscode.TestItem, itemCommand: ItemCommand): Promise { + this.enableOutputOnRun(); + await this.exitDeferMode(); + return this.testProvider?.runItemCommand(testItem, itemCommand); } enableLoginShell(): void { if (this.extContext.settings.shell.useLoginShell) { @@ -846,13 +887,11 @@ export class JestExt { } private resetStatusBar(): void { - const modes: Mode[] = []; - if (this.coverageOverlay.enabled) { - modes.push('coverage'); - } - modes.push(this.extContext.settings.autoRun.mode); - - this.updateStatusBar({ state: 'initial', mode: modes, stats: emptyTestStats() }); + this.updateStatusBar({ + state: 'initial', + mode: this.extContext.settings.runMode, + stats: emptyTestStats(), + }); } private updateStatusBar(status: StatusBarUpdate): void { this.status.update(status); diff --git a/src/JestExt/helper.ts b/src/JestExt/helper.ts index cf7a8f5ad..3606334e1 100644 --- a/src/JestExt/helper.ts +++ b/src/JestExt/helper.ts @@ -13,13 +13,16 @@ import { JestExtAutoRunSetting, AutoRevealOutputType, createJestSettingGetter, + JestRunModeType, + JestRunMode, + DeprecatedPluginResourceSettings, } from '../Settings'; import { workspaceLogging } from '../logging'; import { JestExtContext, RunnerWorkspaceOptions } from './types'; import { CoverageColors } from '../Coverage'; import { userInfo } from 'os'; import { JestOutputTerminal } from './output-terminal'; -import { AutoRun } from './auto-run'; +import { RunMode } from './run-mode'; import { RunShell } from './run-shell'; import { toAbsoluteRootPath, toFilePath } from '../helpers'; @@ -63,7 +66,7 @@ export const createJestExtContext = ( '', currentJestVersion, outputFileSuffix(ws, options?.outputFileSuffix), - options?.collectCoverage ?? settings.showCoverageOnLoad, + options?.collectCoverage ?? settings.runMode.config.coverage ?? false, settings.debugMode, settings.nodeEnv, settings.shell.toSetting(), @@ -109,11 +112,16 @@ export const getExtensionResourceSettings = ( ): PluginResourceSettings => { const getSetting = createJestSettingGetter(workspaceFolder); + const deprecatedSettings: DeprecatedPluginResourceSettings = { + showCoverageOnLoad: getSetting('showCoverageOnLoad') ?? false, + autoRevealOutput: getSetting('autoRevealOutput') ?? 'on-run', + autoRun: getSetting('autoRun'), + }; + return { jestCommandLine: getSetting('jestCommandLine'), autoClearTerminal: getSetting('autoClearTerminal') ?? false, rootPath: toAbsoluteRootPath(workspaceFolder, getSetting('rootPath')), - showCoverageOnLoad: getSetting('showCoverageOnLoad') ?? false, coverageFormatter: getSetting('coverageFormatter') ?? 'DefaultFormatter', debugMode: getSetting('debugMode'), coverageColors: getSetting('coverageColors'), @@ -123,8 +131,10 @@ export const getExtensionResourceSettings = ( nodeEnv: getSetting('nodeEnv') ?? undefined, shell: new RunShell(getSetting('shell')), monitorLongRun: getSetting('monitorLongRun') ?? undefined, - autoRun: new AutoRun(getSetting('autoRun')), - autoRevealOutput: getSetting('autoRevealOutput') ?? 'on-run', + runMode: new RunMode( + getSetting('runMode'), + deprecatedSettings + ), parserPluginOptions: getSetting('parserPluginOptions'), enable: getSetting('enable'), useDashedArgs: getSetting('useDashedArgs') ?? false, diff --git a/src/JestExt/index.ts b/src/JestExt/index.ts index 9957dd088..a5a9e0535 100644 --- a/src/JestExt/index.ts +++ b/src/JestExt/index.ts @@ -1,3 +1,2 @@ export * from './core'; export * from './types'; -export * from './auto-run'; diff --git a/src/JestExt/output-terminal.ts b/src/JestExt/output-terminal.ts index df2295c91..3f9a55824 100644 --- a/src/JestExt/output-terminal.ts +++ b/src/JestExt/output-terminal.ts @@ -43,7 +43,7 @@ export class ExtOutputTerminal implements JestExtOutput { private ptyIsOpen: boolean; private writeEmitter = new vscode.EventEmitter(); private _terminal?: vscode.Terminal; - private canReveal: boolean; + private enabled: boolean; public revealOnError: boolean; private pty: vscode.Pseudoterminal = { @@ -59,14 +59,14 @@ export class ExtOutputTerminal implements JestExtOutput { }, close: () => { this.ptyIsOpen = false; - this.canReveal = false; + this.enabled = false; this._terminal = undefined; }, }; - constructor(private name: string, visible?: boolean) { + constructor(private name: string, enabled?: boolean) { this.ptyIsOpen = false; this.pendingMessages = new PendingOutput(); - this.canReveal = visible ?? false; + this.enabled = enabled ?? false; this.revealOnError = true; } @@ -76,17 +76,17 @@ export class ExtOutputTerminal implements JestExtOutput { * if there are other active terminal * @returns */ - reveal(): void { - if (this.canReveal) { + enable(): void { + if (this.enabled) { return; } - this.canReveal = true; + this.enabled = true; this.createTerminalIfNeeded(); } /** delay creating terminal until we are actually running the tests */ private createTerminalIfNeeded() { - if (!this.canReveal || this._terminal) { + if (!this.enabled || this._terminal) { return; } vscode.window.terminals.forEach((t) => { @@ -117,16 +117,17 @@ export class ExtOutputTerminal implements JestExtOutput { const text = toAnsi(msg, opt); this.appendRaw(text); - if (isErrorOutputType(opt) && this.revealOnError) { + if (isErrorOutputType(opt) && (this.enabled || this.revealOnError)) { this.show(); } return text; } show(): void { - this.reveal(); + this.enable(); this._terminal?.show(true); } close(): void { + this.enabled = false; this._terminal?.dispose(); } dispose(): void { @@ -207,16 +208,13 @@ const applyAnsiSeq = (text: string, opt: OutputOptionShort, useLite = false): st } }; -export const toAnsi = (msg: string, options?: OutputOptions): string => - _toAnsi(msg, options, false); -const _toAnsi = (msg: string, options?: OutputOptions, useLite = false): string => { +export const toAnsi = (msg: string, options?: OutputOptions, useLite = false): string => { let text = msg.replace(/\n/g, '\r\n'); if (!options) { return text; } if (Array.isArray(options)) { - const _useLite = useLite || options.includes('lite'); - return options.reduce((t, opt) => _toAnsi(t, opt, _useLite), msg); + return options.reduce((t, opt) => toAnsi(t, opt, useLite || options.includes('lite')), msg); } if (typeof options === 'string') { diff --git a/src/JestExt/process-listeners.ts b/src/JestExt/process-listeners.ts index 1ba28d7a4..a9385993b 100644 --- a/src/JestExt/process-listeners.ts +++ b/src/JestExt/process-listeners.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; -import { JestTotalResults } from 'jest-editor-support'; +import { JestTotalResults, RunnerEvent } from 'jest-editor-support'; import { cleanAnsi, toErrorString } from '../helpers'; -import { JestProcess, JestProcessEvent } from '../JestProcessManagement'; +import { JestProcess } from '../JestProcessManagement'; import { ListenerSession, ListTestFilesCallback } from './process-session'; import { Logging } from '../logging'; import { JestRunEvent } from './types'; @@ -33,12 +33,8 @@ export class AbstractProcessListener { return 'AbstractProcessListener'; } - onEvent(jestProcess: JestProcess, event: JestProcessEvent, ...args: unknown[]): void { + onEvent(jestProcess: JestProcess, event: RunnerEvent, ...args: unknown[]): void { switch (event) { - case 'processStarting': { - this.onProcessStarting(jestProcess); - break; - } case 'executableStdErr': { const data = (args[0] as Buffer).toString(); this.onExecutableStdErr(jestProcess, cleanAnsi(data), data); @@ -68,18 +64,9 @@ export class AbstractProcessListener { this.onProcessExit(jestProcess, code ?? undefined, signal ?? undefined); break; } - default: - this.logging( - 'warn', - `received unexpected event "${event}" for process:`, - jestProcess.request - ); } } - protected onProcessStarting(process: JestProcess): void { - this.session.context.onRunEvent.fire({ type: 'process-start', process }); - } protected onExecutableStdErr(_process: JestProcess, data: string, _raw: string): void { if (POSSIBLE_ENV_ERROR_REGEX.test(data)) { this.CmdNotFoundEnv = true; @@ -356,12 +343,15 @@ export class RunTestListener extends AbstractProcessListener { protected onProcessClose(process: JestProcess, code?: number, signal?: string): void { this.runEnded(); - const error = this.handleWatchProcessCrash(process); + let error = this.handleWatchProcessCrash(process); if (code && code > 1) { if (this.retryWithLoginShell(process, code, signal)) { return; } + if (!error) { + error = `process ${process.id} exited with code= ${code}`; + } } this.onRunEvent.fire({ type: 'exit', process, error, code }); } diff --git a/src/JestExt/process-session.ts b/src/JestExt/process-session.ts index d320b5936..da789af99 100644 --- a/src/JestExt/process-session.ts +++ b/src/JestExt/process-session.ts @@ -6,6 +6,7 @@ import { requestString, JestProcessInfo, JestProcessRequestTransform, + UserDataType, } from '../JestProcessManagement'; import { JestTestProcessType } from '../Settings'; import { RunTestListener, ListTestFileListener } from './process-listeners'; @@ -68,7 +69,8 @@ export interface ProcessSession { start: () => Promise; stop: () => Promise; scheduleProcess: ( - request: T + request: T, + userData?: UserDataType ) => JestProcessInfo | undefined; } export interface ListenerSession { @@ -88,13 +90,14 @@ export const createProcessSession = (context: JestExtProcessContext): ProcessSes * @param stopRunning if true, will stop and remove processes with the same type, default is false */ const scheduleProcess = ( - request: T + request: T, + userData?: UserDataType ): JestProcessInfo | undefined => { logging('debug', `scheduling jest process: ${request.type}`); try { const pRequest = createProcessRequest(request); - const process = jestProcessManager.scheduleJestProcess(pRequest); + const process = jestProcessManager.scheduleJestProcess(pRequest, userData); if (!process) { logging('warn', `request schedule failed: ${requestString(pRequest)}`); return; @@ -161,10 +164,10 @@ export const createProcessSession = (context: JestExtProcessContext): ProcessSes await stop(); } - if (context.settings.autoRun.onStartup) { - context.settings.autoRun.onStartup.forEach((type) => scheduleProcess({ type })); + if (context.settings.runMode.config.runAllTestsOnStartup) { + scheduleProcess({ type: 'all-tests' }); } - if (context.settings.autoRun.isWatch) { + if (context.settings.runMode.config.type === 'watch') { scheduleProcess({ type: 'watch-tests' }); } }; diff --git a/src/JestExt/run-mode.ts b/src/JestExt/run-mode.ts new file mode 100644 index 000000000..5dc8e85b9 --- /dev/null +++ b/src/JestExt/run-mode.ts @@ -0,0 +1,535 @@ +import * as vscode from 'vscode'; +import { + DeprecatedPluginResourceSettings, + JestExtAutoRunSetting, + JestPredefinedRunModeType, + JestRunMode, + JestRunModeSetting, + JestRunModeType, + updateSetting, +} from '../Settings'; +import { NoOpFileSystemProvider } from '../noop-fs-provider'; + +const runModeTypes: JestRunModeType[] = ['watch', 'on-save', 'on-demand']; +const predefinedRunModeTypes: JestPredefinedRunModeType[] = [...runModeTypes, 'deferred']; + +const isJestRunMode = (obj: JestRunModeSetting | null | undefined): obj is JestRunMode => + obj != null && typeof obj !== 'string' && 'type' in obj; + +interface RunModeQuickPickItem extends vscode.QuickPickItem { + mode: JestRunMode; + isCurrent?: boolean; +} +interface RunModeQuickPickButton extends vscode.QuickInputButton { + action: () => Promise; +} + +export interface RunModeIcon { + icon: string; + label: string; +} +export interface RunModeDescription { + type: RunModeIcon; + coverage?: RunModeIcon; + deferred?: RunModeIcon; +} + +export const RunModeIcons: Record = { + watch: { icon: '$(eye)', label: 'watch' }, + 'on-save': { icon: '$(save-all)', label: 'on-save' }, + 'on-save-test-file-only': { icon: '$(save)', label: 'on-save-test-file-only' }, + 'on-demand': { icon: '$(run)', label: 'on-demand' }, + coverage: { icon: '$(color-mode)', label: 'coverage' }, + deferred: { icon: '$(debug-pause)', label: 'deferred' }, +}; + +export class RunMode { + private _config: JestRunMode; + private origConfig: Readonly; + private _isModified = false; + + constructor( + setting?: JestRunMode | JestRunModeType | null, + legacySettings?: DeprecatedPluginResourceSettings + ) { + this._config = this.toRunMode(setting, legacySettings); + this.origConfig = { ...this._config }; + } + + /** returns readonly config */ + public get config(): Readonly { + return this._config; + } + public get isModified(): Readonly { + return this._isModified; + } + + public exitDeferMode() { + this._config.deferred = false; + this._isModified = true; + } + + public toggleCoverage() { + this._config.coverage = !this._config.coverage; + this._isModified = true; + } + + static validate(config: JestRunModeSetting): void { + let errConfigType: string | undefined; + if (isJestRunMode(config) && !runModeTypes.includes(config.type)) { + errConfigType = config.type; + } else if (typeof config === 'string' && !predefinedRunModeTypes.includes(config)) { + errConfigType = config; + } + + if (errConfigType) { + throw new Error( + `Invalid type "${errConfigType}" in jest.runMode setting: ${JSON.stringify(config)}` + ); + } + } + + private getDefaultRunMode(setting: JestPredefinedRunModeType): JestRunMode { + switch (setting.toLocaleLowerCase()) { + case 'watch': + return { type: 'watch', revealOutput: 'on-run' }; + case 'on-save': + return { type: 'on-save', revealOutput: 'on-run' }; + case 'on-demand': + return { type: 'on-demand', revealOutput: 'on-run' }; + case 'deferred': + return { + ...this.getDefaultRunMode('on-demand'), + deferred: true, + }; + default: { + // istanbul ignore next + throw new Error(`invalid runMode ${setting}`); + } + } + } + private fromAutoRun(setting: JestExtAutoRunSetting): JestRunMode { + let runMode: JestRunMode; + if (typeof setting === 'string') { + switch (setting) { + case 'default': + case 'watch': + runMode = this.getDefaultRunMode('watch'); + break; + case 'on-save': + runMode = this.getDefaultRunMode('on-save'); + break; + case 'legacy': + runMode = this.getDefaultRunMode('watch') as JestRunMode; + runMode.runAllTestsOnStartup = true; + break; + case 'off': + runMode = this.getDefaultRunMode('on-demand'); + break; + default: + throw new Error(`invalid autoRun ${setting}`); + } + } else { + if (setting.watch) { + runMode = this.getDefaultRunMode('watch'); + } else { + runMode = this.getDefaultRunMode('on-demand'); + if ('onSave' in setting && setting.onSave) { + runMode = this.getDefaultRunMode('on-save'); + if (runMode.type === 'on-save' && setting.onSave === 'test-file') { + runMode.testFileOnly = true; + } + } + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((setting as any).onStartup?.includes('all-tests')) { + runMode.runAllTestsOnStartup = true; + } + console.warn(`"autoRun" is deprecated and replaced by "runMode": ${JSON.stringify(runMode)}`); + return runMode; + } + private toRunMode( + setting?: JestRunModeSetting | null, + legacySettings?: DeprecatedPluginResourceSettings + ): JestRunMode { + try { + if (setting) { + RunMode.validate(setting); + + if (isJestRunMode(setting)) { + return { ...setting }; + } + return this.getDefaultRunMode(setting); + } + + // Determine the base run mode based on the provided setting, or fallback to legacy settings or default 'watch' mode. + // If a setting is provided, use the default run mode for that setting. + // If no setting is provided, check if legacy autoRun is enabled and use its run mode. + // If neither setting nor legacy autoRun is available, use the default 'watch' mode. + + const base = ( + legacySettings?.autoRun + ? this.fromAutoRun(legacySettings.autoRun) + : this.getDefaultRunMode('watch') + ) as JestRunMode; + + if (legacySettings?.showCoverageOnLoad) { + base.coverage = true; + } + if (legacySettings?.autoRevealOutput) { + switch (legacySettings.autoRevealOutput) { + case 'on-run': + case 'on-exec-error': + base.revealOutput = legacySettings.autoRevealOutput; + break; + case 'off': + base.revealOutput = 'on-demand'; + break; + default: + throw new Error(`invalid autoRevealOutput ${legacySettings.autoRevealOutput}`); + } + } + return base; + } catch (e) { + // sever user error, while we can fallback to the default, it might not be what the user intended. + // therefore raise a prominent error message. + // This should only happen when experienced user specified a runMode with the wrong value, i.e. rare, and should not happen to new users. + const message = `invalid runMode ${JSON.stringify( + setting + )}, will use default RunMode instead`; + console.error(message); + vscode.window.showErrorMessage(message); + return this.getDefaultRunMode('watch'); + } + } + + protected clone(config: JestRunMode): RunMode { + const newRunMode = new RunMode(this.origConfig); + newRunMode._config = { ...config }; + return newRunMode; + } + + /** + * pop up a chooser to allow user change runMode + * @returns the new runMode or undefined if nothing is changed + */ + public async quickSwitch(context: vscode.ExtensionContext): Promise { + const runModeEditor = new RunModeEditor(); + const itemButtons = (mode: JestRunMode): RunModeQuickPickButton[] => { + const coverageIcon = mode.coverage + ? vscode.Uri.file(context.asAbsolutePath('icons/coverage-on-20.svg')) + : new vscode.ThemeIcon('color-mode'); + const coverageButton = { + iconPath: coverageIcon, + tooltip: `toggle coverage ${mode.coverage ? 'off' : 'on'}`, + action: () => { + mode.coverage = !(mode.coverage ?? false); + return Promise.resolve(mode); + }, + }; + const deferredIcon = mode.deferred + ? vscode.Uri.file(context.asAbsolutePath('icons/pause-on-20.svg')) + : new vscode.ThemeIcon('debug-pause'); + const runModeSchemaUri = vscode.Uri.file( + context.asAbsolutePath('syntaxes/jestRunModeSchema.json') + ); + const deferredButton = { + iconPath: deferredIcon, + tooltip: `toggle deferred ${mode.deferred ? 'off' : 'on'}`, + action: () => { + mode.deferred = !(mode.deferred ?? false); + return Promise.resolve(mode); + }, + }; + const editButton = { + iconPath: new vscode.ThemeIcon('edit'), + tooltip: `edit the runMode`, + action: async () => { + const edited = await runModeEditor.edit(mode, runModeSchemaUri); + return edited ? this.toRunMode(edited) : mode; + }, + }; + + return [coverageButton, deferredButton, editButton]; + }; + + const runModeItem = (type: JestRunModeType): RunModeQuickPickItem => { + let mode = this.getDefaultRunMode(type); + + const isCurrent = mode.type === this.config.type; + if (isCurrent) { + mode = { ...this._config }; + } + + const typeLabel = runModeDescription(mode).type; + + return { + label: `${typeLabel.icon} ${mode.type}`, + description: isCurrent ? '(current)' : undefined, + isCurrent, + mode, + buttons: itemButtons(mode), + }; + }; + + // create items + const items: RunModeQuickPickItem[] = runModeTypes.map((type) => runModeItem(type)); + let restoreOriginalItem: RunModeQuickPickItem | undefined; + + if (this._isModified) { + restoreOriginalItem = { + label: '$(sync) Restore original runMode', + description: ` ("${typeIcon(this.origConfig).label}")`, + mode: { ...this.origConfig }, + }; + items.push({ label: '', mode: { type: 'watch' }, kind: vscode.QuickPickItemKind.Separator }); + items.push(restoreOriginalItem); + } + + // showing the quickPick + const pickedItem = await showRunModeQuickPick(items, itemButtons); + + // make sure any open runMode editor is closed + await runModeEditor.close(); + + if (pickedItem) { + const newRunMode = this.clone(pickedItem.mode); + if (pickedItem === restoreOriginalItem) { + newRunMode._isModified = false; + } else { + newRunMode._isModified = true; + } + return newRunMode; + } + } + + /** + * save runMode to the workspace settings + * @returns + */ + public save = (workspaceFolder: vscode.WorkspaceFolder): Promise => { + return updateSetting(workspaceFolder, 'runMode', this.config); + }; +} + +const showRunModeQuickPick = async ( + items: RunModeQuickPickItem[], + itemButtons: (mode: JestRunMode) => RunModeQuickPickButton[] +): Promise => { + const acceptButton = { + iconPath: new vscode.ThemeIcon('check'), + tooltip: 'accept the selected runMode', + }; + const quickPick = vscode.window.createQuickPick(); + quickPick.items = items; + quickPick.title = 'Change RunMode'; + // quickPick.placeholder = 'Select the desired run mode for the current session'; + quickPick.ignoreFocusOut = true; + quickPick.canSelectMany = false; + quickPick.buttons = [vscode.QuickInputButtons.Back, acceptButton]; + let active = items.find((item) => item.isCurrent) ?? items[0]; + quickPick.activeItems = [active]; + + return new Promise((resolve) => { + let picked: RunModeQuickPickItem | undefined; + let fixActiveHack = 0; + quickPick.onDidTriggerButton(async (button) => { + picked = button === vscode.QuickInputButtons.Back ? undefined : quickPick.activeItems[0]; + quickPick.hide(); + }); + quickPick.onDidTriggerItemButton(async (e) => { + const item = e.item; + if (item) { + active = item; + quickPick.activeItems = [active]; + + const m = await (e.button as RunModeQuickPickButton).action(); + const found = items.find((item) => item.mode.type === m.type); + + /* this following condition should not happen, and can't really be tested based on today's implementation, */ + /* but decided to leave it here anyway for error proof... */ + /* istanbul ignore next */ + if (!found) { + vscode.window.showErrorMessage(`Disregard changes: invalid runMode type: ${m.type}.`); + return; + } + // active might have changed, so we need to update it + active = found; + quickPick.activeItems = [active]; + + found.mode = m; + found.buttons = itemButtons(found.mode); + quickPick.items = [...items]; + + // hack to fix the active item not showing up after items being reset + // see issue: https://github.com/microsoft/vscode/issues/75005 + fixActiveHack = 2; + + quickPick.activeItems = [active]; + } + }); + quickPick.onDidChangeActive(() => { + // hack to fix the active item not showing up after items being reset + // see issue: https://github.com/microsoft/vscode/issues/75005 + if (fixActiveHack !== 0) { + fixActiveHack--; + quickPick.activeItems = [active]; + return; + } + }); + quickPick.onDidChangeSelection((selectedItems) => { + // disable 'selection" since we work with 'active' item only. + // With both active and select appearances is quite confusing, therefore, we disable the selection here. + if (selectedItems.length > 0) { + quickPick.selectedItems = []; + } + }); + quickPick.onDidHide(() => { + quickPick.dispose(); + resolve(picked); + }); + quickPick.show(); + }); +}; + +const RunModeEditInstruction = ` +// Save the file to accept the change. +// close without saving to cancel the change. +// RunMode reference: https://github.com/jest-community/vscode-jest/blob/master/README.md#runmode +`; + +export class RunModeEditor { + // private doc?: vscode.TextDocument; + private disposables: vscode.Disposable[] = []; + private docUri = vscode.Uri.parse(`${NoOpFileSystemProvider.scheme}://workspace/runMode.json`); + + private dispose = () => { + this.disposables.forEach((d) => d.dispose()); + this.disposables = []; + this.cancelEdit = undefined; + }; + private cancelEdit: (() => void) | undefined; + + async edit(config: JestRunMode, schemaUri: vscode.Uri): Promise { + this.dispose(); + + const jsonObject = { + $schema: schemaUri.toString(), + 'jest.runMode': config, + }; + const content = RunModeEditInstruction + JSON.stringify(jsonObject, null, 4); + + const doc = await vscode.workspace.openTextDocument(this.docUri); + await vscode.languages.setTextDocumentLanguage(doc, 'jsonc'); + const editor = await vscode.window.showTextDocument(doc, { preview: false }); + await editor.edit((editBuilder) => { + editBuilder.replace( + new vscode.Range( + editor.document.lineAt(0).range.start, + editor.document.lineAt(editor.document.lineCount - 1).range.end + ), + content + ); + }); + // do this to make sure the document didn't show up as changed + await doc.save(); + + return new Promise((_resolve) => { + // let edited: JestRunModeSetting | undefined; + let resolved = false; + + const resolve = (value?: JestRunModeSetting) => { + if (!resolved) { + _resolve(value); + resolved = true; + this.dispose(); + } + }; + this.cancelEdit = () => resolve(undefined); + + this.disposables.push( + vscode.workspace.onDidSaveTextDocument((document) => { + if (document === doc) { + try { + // Remove the comments from the document text + let jsonText = document.getText(); + jsonText = jsonText.slice(jsonText.indexOf('{')); + if (jsonText) { + // Parse the JSON content to validate it + const jsonObject = JSON.parse(jsonText); + const edited = jsonObject['jest.runMode']; + if (edited) { + RunMode.validate(edited); + resolve(edited); + // Close the active editor + vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + return; + } + } + throw new Error(`invalid runMode JSON content`); + } catch (error) { + // Show parse error + vscode.window.showErrorMessage('RunMode is invalid: ' + error); + } + } + }) + ); + this.disposables.push( + vscode.workspace.onDidCloseTextDocument(async (closedDoc) => { + if (closedDoc.uri.toString() === doc.uri.toString()) { + resolve(); + } + }) + ); + }); + } + async close() { + this.cancelEdit?.(); + + // find the editor for the docUri + const uri = this.docUri.toString(); + const editor = vscode.window.visibleTextEditors.find( + (editor) => editor.document.uri.toString() === uri + ); + if (editor) { + // force save the document to noop fs so we can close the editor without prompt + await editor.document.save(); + + // since there didn't seem to have a way to close the given editor, we have to work around by + // making the target editor active then close the activeEditor + await vscode.window.showTextDocument(editor.document, editor.viewColumn); + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + } + this.dispose(); + } +} + +export const runModeDescription = (config: JestRunMode): RunModeDescription => { + let description: RunModeDescription; + switch (config.type) { + case 'watch': + case 'on-demand': + description = { type: RunModeIcons[config.type] }; + break; + case 'on-save': + if (config.testFileOnly) { + description = { type: RunModeIcons['on-save-test-file-only'] }; + } else { + description = { type: RunModeIcons['on-save'] }; + } + break; + } + if (config.coverage) { + description.coverage = RunModeIcons['coverage']; + } + if (config.deferred) { + description.deferred = RunModeIcons['deferred']; + } + return description; +}; + +export const typeIcon = (mode: JestRunMode): RunModeIcon => { + const desc = runModeDescription(mode); + return desc.deferred ?? desc.type; +}; diff --git a/src/JestProcessManagement/JestProcess.ts b/src/JestProcessManagement/JestProcess.ts index 4881caf45..e96feebb2 100644 --- a/src/JestProcessManagement/JestProcess.ts +++ b/src/JestProcessManagement/JestProcess.ts @@ -4,7 +4,7 @@ import { Runner, RunnerEvent, Options } from 'jest-editor-support'; import { JestExtContext, WatchMode } from '../JestExt/types'; import { extensionId } from '../appGlobals'; import { Logging } from '../logging'; -import { JestProcessInfo, JestProcessRequest } from './types'; +import { JestProcessInfo, JestProcessRequest, UserDataType } from './types'; import { requestString } from './helper'; import { toFilePath, removeSurroundingQuote, escapeRegExp, shellQuote } from '../helpers'; @@ -38,7 +38,11 @@ export class JestProcess implements JestProcessInfo { private desc: string; public readonly request: JestProcessRequest; - constructor(extContext: JestExtContext, request: JestProcessRequest) { + constructor( + extContext: JestExtContext, + request: JestProcessRequest, + public userData?: UserDataType + ) { this.extContext = extContext; this.request = request; this.logging = extContext.loggingFactory.create(`JestProcess ${request.type}`); @@ -185,7 +189,6 @@ export class JestProcess implements JestProcessInfo { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.task = { ...taskInfo!, promise }; - this.request.listener.onEvent(this, 'processStarting'); runner.start(this.watchMode !== WatchMode.None, this.watchMode === WatchMode.WatchAll); return promise; diff --git a/src/JestProcessManagement/JestProcessManager.ts b/src/JestProcessManagement/JestProcessManager.ts index 47cfd5b14..eba817532 100644 --- a/src/JestProcessManagement/JestProcessManager.ts +++ b/src/JestProcessManagement/JestProcessManager.ts @@ -1,16 +1,23 @@ import { JestProcess } from './JestProcess'; -import { TaskArrayFunctions, JestProcessRequest, QueueType, Task, JestProcessInfo } from './types'; +import { + TaskArrayFunctions, + JestProcessRequest, + QueueType, + Task, + JestProcessInfo, + UserDataType, +} from './types'; import { Logging } from '../logging'; import { createTaskQueue, TaskQueue } from './task-queue'; import { isDupe, requestString } from './helper'; -import { JestExtContext } from '../JestExt'; +import { JestExtProcessContext } from '../JestExt'; export class JestProcessManager implements TaskArrayFunctions { - private extContext: JestExtContext; + private extContext: JestExtProcessContext; private queues: Map>; private logging: Logging; - constructor(extContext: JestExtContext) { + constructor(extContext: JestExtProcessContext) { this.extContext = extContext; this.logging = extContext.loggingFactory.create('JestProcessManager'); this.queues = new Map([ @@ -46,7 +53,10 @@ export class JestProcessManager implements TaskArrayFunctions { * @param request * @returns a jest process id if successfully scheduled, otherwise undefined */ - public scheduleJestProcess(request: JestProcessRequest): JestProcessInfo | undefined { + public scheduleJestProcess( + request: JestProcessRequest, + userData?: UserDataType + ): JestProcessInfo | undefined { if (this.foundDup(request)) { this.logging( 'debug', @@ -56,7 +66,7 @@ export class JestProcessManager implements TaskArrayFunctions { } const queue = this.getQueue(request.schedule.queue); - const process = new JestProcess(this.extContext, request); + const process = new JestProcess(this.extContext, request, userData); queue.add(process); this.run(queue); return process; @@ -71,9 +81,16 @@ export class JestProcessManager implements TaskArrayFunctions { const process = task.data; try { - await process.start(); + const promise = process.start(); + this.extContext.onRunEvent.fire({ type: 'process-start', process }); + await promise; } catch (e) { - this.logging('error', `${queue.name}: process failed:`, process, e); + this.logging('error', `${queue.name}: process failed to start:`, process, e); + this.extContext.onRunEvent.fire({ + type: 'exit', + process, + error: `Process failed to start: ${e}`, + }); } finally { queue.remove(task); } diff --git a/src/JestProcessManagement/types.ts b/src/JestProcessManagement/types.ts index 202d97c95..e126250ab 100644 --- a/src/JestProcessManagement/types.ts +++ b/src/JestProcessManagement/types.ts @@ -1,15 +1,22 @@ import { RunnerEvent } from 'jest-editor-support'; import { JestTestProcessType } from '../Settings'; import { JestProcess } from './JestProcess'; +import { JestTestRun } from '../test-provider/test-provider-helper'; -export type JestProcessEvent = RunnerEvent | 'processStarting'; export interface JestProcessListener { - onEvent: (process: JestProcess, event: JestProcessEvent, ...args: unknown[]) => unknown; + onEvent: (process: JestProcess, event: RunnerEvent, ...args: unknown[]) => unknown; } export type JestProcessStatus = 'pending' | 'running' | 'stopping' | 'stopped'; +export interface UserDataType { + run?: JestTestRun; + errorReported?: boolean; +} export interface JestProcessInfo { readonly id: string; readonly request: JestProcessRequest; + // user data is a way to store data that is outside of the process managed by the processManager. + // subsequent use of this data is up to the user but should be aware that multiple components might contribute to this data. + userData?: UserDataType; } export type TaskStatus = 'running' | 'pending'; diff --git a/src/Settings/index.ts b/src/Settings/index.ts index 533ad290e..55cc0060c 100644 --- a/src/Settings/index.ts +++ b/src/Settings/index.ts @@ -1,9 +1,9 @@ import * as vscode from 'vscode'; import { CoverageColors } from '../Coverage/CoverageOverlay'; import { JESParserPluginOptions, ProjectWorkspace } from 'jest-editor-support'; -import { AutoRun } from '../JestExt/auto-run'; import { RunShell } from '../JestExt/run-shell'; import { isVirtualWorkspaceFolder } from '../virtual-workspace-folder'; +import { RunMode } from '../JestExt/run-mode'; export type JestTestProcessType = | 'all-tests' @@ -28,6 +28,23 @@ export type JestExtAutoRunConfig = }; export type JestExtAutoRunSetting = JestExtAutoRunShortHand | JestExtAutoRunConfig; +interface JestRunModeOptions { + runAllTestsOnStartup?: boolean; + coverage?: boolean; + revealOutput?: 'on-run' | 'on-exec-error' | 'on-demand'; + deferred?: boolean; +} +export type JestRunMode = ( + | { type: 'watch' } + | { type: 'on-demand' } + | { type: 'on-save'; testFileOnly?: boolean } +) & + JestRunModeOptions; + +export type JestRunModeType = JestRunMode['type']; +export type JestPredefinedRunModeType = JestRunModeType | 'deferred'; +export type JestRunModeSetting = JestRunMode | JestPredefinedRunModeType; + export type TestExplorerConfigLegacy = | { enabled: false } | { enabled: true; showClassicStatus?: boolean; showInlineError?: boolean }; @@ -43,30 +60,35 @@ export interface PluginResourceSettings { jestCommandLine?: string; autoClearTerminal?: boolean; rootPath: string; - showCoverageOnLoad: boolean; coverageFormatter: string; debugMode?: boolean; coverageColors?: CoverageColors; - autoRun: AutoRun; + runMode: RunMode; testExplorer: TestExplorerConfig; nodeEnv?: NodeEnv; shell: RunShell; monitorLongRun?: MonitorLongRun; - autoRevealOutput: AutoRevealOutputType; - parserPluginOptions?: JESParserPluginOptions; enable?: boolean; + parserPluginOptions?: JESParserPluginOptions; useDashedArgs?: boolean; } +export interface DeprecatedPluginResourceSettings { + showCoverageOnLoad?: boolean; + autoRevealOutput?: AutoRevealOutputType; + autoRun?: JestExtAutoRunSetting | null; +} + export interface PluginWindowSettings { disabledWorkspaceFolders: string[]; } -export type VirtualFolderSettingKey = keyof PluginResourceSettings; -export type VirtualFolderSettings = { +export type AllPluginResourceSettings = PluginResourceSettings & DeprecatedPluginResourceSettings; + +export type VirtualFolderSettingKey = keyof AllPluginResourceSettings; +export interface VirtualFolderSettings extends AllPluginResourceSettings { name: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -} & Record; +} export type GetConfigFunction = (key: VirtualFolderSettingKey) => T | undefined; /** @@ -97,7 +119,28 @@ export const createJestSettingGetter = ( return (config.get(key) !== false && vFolder?.enable !== false) as T; } - return vFolder?.[key] ?? config.get(key); + return (vFolder?.[key] as T) ?? config.get(key); }; return getSetting; }; + +// get setting from virtual folder first, fallback to workspace setting if not found +export const updateSetting = async ( + workspaceFolder: vscode.WorkspaceFolder, + key: VirtualFolderSettingKey, + value: unknown +): Promise => { + const config = vscode.workspace.getConfiguration('jest', workspaceFolder.uri); + if (!isVirtualWorkspaceFolder(workspaceFolder)) { + await config.update(key, value); + return; + } + const virtualFolders = config.get('virtualFolders'); + const vFolder = virtualFolders?.find((v) => v.name === workspaceFolder.name); + if (!vFolder) { + throw new Error(`[${workspaceFolder.name}] is missing corresponding virtual folder setting`); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (vFolder as any)[key] = value; + await config.update('virtualFolders', virtualFolders); +}; diff --git a/src/StatusBar.ts b/src/StatusBar.ts index 0b5130466..1a59aea98 100644 --- a/src/StatusBar.ts +++ b/src/StatusBar.ts @@ -4,6 +4,7 @@ import { JestExt } from './JestExt'; import { TestStats, TestStatsCategory } from './types'; import { VirtualFolderBasedCache } from './virtual-workspace-folder'; import { isInFolder } from './workspace-manager'; +import { RunMode, runModeDescription } from './JestExt/run-mode'; export enum StatusType { active, @@ -11,18 +12,11 @@ export enum StatusType { } export type ProcessState = 'running' | 'success' | 'exec-error' | 'stopped' | 'initial' | 'done'; -export type AutoRunMode = - | 'auto-run-watch' - | 'auto-run-on-save' - | 'auto-run-on-save-test' - | 'auto-run-off'; -export type Mode = AutoRunMode | 'coverage'; - type SummaryState = 'summary-warning' | 'summary-pass' | 'stats-not-sync'; export type SBTestStats = TestStats & { isDirty?: boolean; state?: ProcessState }; export interface ExtensionStatus { - mode?: Mode[]; + mode?: RunMode; stats?: SBTestStats; state?: ProcessState; } @@ -334,27 +328,14 @@ export class StatusBar { ): string { return this.getStateInfo(state, showIcon).label; } - private getModes(modes?: Mode[], showIcon = true): string { - if (!modes || modes.length <= 0) { + private getModes(mode?: RunMode, showIcon = true): string { + if (!mode) { return ''; } - const modesStrings = modes.map((m) => { - if (!showIcon) { - return m; - } - switch (m) { - case 'coverage': - return '$(color-mode)'; - case 'auto-run-watch': - return '$(eye)'; - case 'auto-run-on-save': - return '$(save-all)'; - case 'auto-run-on-save-test': - return '$(save)'; - case 'auto-run-off': - return '$(wrench)'; - } - }); + + const modesStrings = Object.values(runModeDescription(mode.config)) + .map((desc) => (showIcon ? desc.icon : desc.label)) + .filter((s) => s); return modesStrings.join(showIcon ? ' ' : ', '); } diff --git a/src/extension-manager.ts b/src/extension-manager.ts index ad23bbf32..bd1b54594 100644 --- a/src/extension-manager.ts +++ b/src/extension-manager.ts @@ -15,16 +15,11 @@ import { import { ItemCommand } from './test-provider/types'; import { enabledWorkspaceFolders } from './workspace-manager'; import { VirtualFolderBasedCache } from './virtual-workspace-folder'; +import { updateSetting } from './Settings'; +import { showQuickFix } from './quick-fix'; export type GetJestExtByURI = (uri: vscode.Uri) => JestExt[]; -export function addFolderToDisabledWorkspaceFolders(folder: string): void { - const config = vscode.workspace.getConfiguration('jest'); - const disabledWorkspaceFolders = new Set(config.get('disabledWorkspaceFolders') ?? []); - disabledWorkspaceFolders.add(folder); - config.update('disabledWorkspaceFolders', [...disabledWorkspaceFolders]); -} - export type RegisterCommand = | { type: 'all-workspaces'; @@ -120,14 +115,22 @@ export class ExtensionManager { return; } - const jestExt = new JestExt( - this.context, - workspaceFolder, - this.debugConfigurationProvider, - this.coverageCodeLensProvider - ); - this.extCache.addItem(jestExt); - jestExt.startSession(); + try { + const jestExt = new JestExt( + this.context, + workspaceFolder, + this.debugConfigurationProvider, + this.coverageCodeLensProvider + ); + this.extCache.addItem(jestExt); + jestExt.startSession(); + } catch (e) { + this.extCache.deleteItemByFolder(workspaceFolder); + console.error(`Failed to activate extension for "${workspaceFolder.name}":`, e); + vscode.window.showErrorMessage( + `Failed to activate extension for "${workspaceFolder.name}": ${e}` + ); + } } deleteExtensionByFolder(workspaceFolder: vscode.WorkspaceFolder): void { @@ -153,12 +156,13 @@ export class ExtensionManager { public getByDocUri: GetJestExtByURI = (uri: vscode.Uri): JestExt[] => { return this.extCache.findRelatedItems(uri) ?? []; }; - private getExtensionsByFolder(folder: vscode.WorkspaceFolder): JestExt[] { - const ext = this.extCache.getItemByFolderName(folder.name); + private getExtensionsByFolder(folder: vscode.WorkspaceFolder | string): JestExt[] { + const name = typeof folder === 'string' ? folder : folder.name; + const ext = this.extCache.getItemByFolderName(name); if (ext) { return [ext]; } - return this.extCache.getItemsByActualFolderName(folder.name) ?? []; + return this.extCache.getItemsByActualFolderName(name) ?? []; } async selectExtension(fromExtensions?: JestExt[]): Promise { @@ -215,7 +219,7 @@ export class ExtensionManager { case 'workspace': { return vscode.commands.registerCommand( commandName, - async (workspace: vscode.WorkspaceFolder, ...args) => { + async (workspace: vscode.WorkspaceFolder | string, ...args) => { const extensions = this.getExtensionsByFolder(workspace); let ext; if (extensions.length > 1) { @@ -354,17 +358,17 @@ export class ExtensionManager { this.registerCommand({ type: 'all-workspaces', name: 'toggle-coverage', - callback: (extension) => extension.toggleCoverageOverlay(), + callback: (extension) => extension.toggleCoverage(), }), this.registerCommand({ type: 'select-workspace', name: 'toggle-coverage', - callback: (extension) => extension.toggleCoverageOverlay(), + callback: (extension) => extension.toggleCoverage(), }), this.registerCommand({ type: 'active-text-editor-workspace', name: 'toggle-coverage', - callback: (extension) => extension.toggleCoverageOverlay(), + callback: (extension) => extension.toggleCoverage(), }), this.registerCommand({ type: 'all-workspaces', @@ -393,19 +397,24 @@ export class ExtensionManager { extension.debugTests(editor.document, ...identifiers); }, }), + this.registerCommand({ + type: 'select-workspace', + name: 'save-run-mode', + callback: (extension) => extension.saveRunMode(), + }), // with-workspace commands this.registerCommand({ type: 'workspace', - name: 'toggle-auto-run', + name: 'change-run-mode', callback: (extension) => { - extension.toggleAutoRun(); + extension.changeRunMode(); }, }), this.registerCommand({ type: 'workspace', name: 'toggle-coverage', callback: (extension) => { - extension.toggleCoverageOverlay(); + extension.toggleCoverage(); }, }), this.registerCommand({ @@ -422,6 +431,42 @@ export class ExtensionManager { extension.runItemCommand(testItem, itemCommand); }, }), + this.registerCommand({ + type: 'workspace', + name: 'exit-defer-mode', + callback: (extension, ...args) => { + extension.exitDeferMode(...args); + }, + }), + this.registerCommand({ + type: 'workspace', + name: 'setup-extension', + callback: (extension, ...args) => { + extension.setupExtensionForFolder(...args); + }, + }), + this.registerCommand({ + type: 'workspace', + name: 'disable', + callback: async (extension) => { + try { + await updateSetting(extension.workspaceFolder, 'enable', false); + this.applySettings(); + } catch (e) { + console.error(`Failed to disable folder "${extension.workspaceFolder.name}`, e); + vscode.window.showErrorMessage( + `Failed to disable folder "${extension.workspaceFolder.name}: ${e}"` + ); + } + }, + }), + this.registerCommand({ + type: 'workspace', + name: 'show-quick-fix', + callback: (extension, ...args) => { + showQuickFix(extension.workspaceFolder.name, args[0]); + }, + }), // setup tool vscode.commands.registerCommand(`${extensionName}.setup-extension`, this.startWizard), diff --git a/src/extension.ts b/src/extension.ts index 48735353d..af88bfa01 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,8 @@ import { statusBar } from './StatusBar'; import { ExtensionManager } from './extension-manager'; import { tiContextManager } from './test-provider/test-item-context-manager'; import * as languageProvider from './language-provider'; +import { noOpFileSystemProvider } from './noop-fs-provider'; +import { executableTerminalLinkProvider } from './terminal-link-provider'; let extensionManager: ExtensionManager; @@ -23,7 +25,9 @@ const addSubscriptions = (context: vscode.ExtensionContext): void => { ...extensionManager.register(), vscode.languages.registerCodeLensProvider(languages, extensionManager.coverageCodeLensProvider), ...tiContextManager.registerCommands(), - ...languageProvider.register() + ...languageProvider.register(), + noOpFileSystemProvider.register(), + executableTerminalLinkProvider.register() ); }; diff --git a/src/messaging.ts b/src/messaging.ts index c0fdfd2a3..37a300517 100644 --- a/src/messaging.ts +++ b/src/messaging.ts @@ -5,9 +5,9 @@ import * as vscode from 'vscode'; export const TROUBLESHOOTING_URL = - 'https://github.com/jest-community/vscode-jest/blob/master/README.md#troubleshooting'; + 'https://github.com/jest-community/vscode-jest/tree/master#troubleshooting'; export const LONG_RUN_TROUBLESHOOTING_URL = - 'https://github.com/jest-community/vscode-jest/blob/master/README.md#what-to-do-with-long-running-tests-warning'; + 'https://github.com/jest-community/vscode-jest/tree/master#what-to-do-with-long-running-tests-warning'; // // internal methods diff --git a/src/noop-fs-provider.ts b/src/noop-fs-provider.ts new file mode 100644 index 000000000..dab2a9876 --- /dev/null +++ b/src/noop-fs-provider.ts @@ -0,0 +1,40 @@ +/* istanbul ignore file */ +/* eslint-disable @typescript-eslint/no-empty-function */ +import * as vscode from 'vscode'; + +/** + * This class is a dummy file system provider, which is used to silence the default file system provider + * behavior, such as prompting user to save the file for untitled file. + */ + +export class NoOpFileSystemProvider implements vscode.FileSystemProvider { + public static scheme = `vscode-jest-noop`; + private _onDidChangeFile: vscode.EventEmitter = + new vscode.EventEmitter(); + readonly onDidChangeFile: vscode.Event = this._onDidChangeFile.event; + + // All methods are no-ops + readFile(): Uint8Array { + return new Uint8Array(); + } + writeFile(): void {} + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + stat(): vscode.FileStat { + return { type: vscode.FileType.File, ctime: 0, mtime: 0, size: 0 }; + } + readDirectory(): [string, vscode.FileType][] { + return []; + } + createDirectory(): void {} + delete(): void {} + rename(): void {} + register(): vscode.Disposable { + return vscode.workspace.registerFileSystemProvider(NoOpFileSystemProvider.scheme, this, { + isCaseSensitive: true, + }); + } +} + +export const noOpFileSystemProvider = new NoOpFileSystemProvider(); diff --git a/src/quick-fix.ts b/src/quick-fix.ts new file mode 100644 index 000000000..b36a2f806 --- /dev/null +++ b/src/quick-fix.ts @@ -0,0 +1,104 @@ +import * as vscode from 'vscode'; +import { extensionName } from './appGlobals'; +import { WizardTaskId } from './setup-wizard'; +import * as messaging from './messaging'; + +export type QuickFixActionType = + | 'help' + | 'wizard' + | 'disable-folder' + | 'defer' + | 'help-long-run' + | 'setup-cmdline' + | 'setup-monorepo'; + +interface QuickFixItem extends vscode.QuickPickItem { + action: () => void; +} + +/** + * Showing configurable quick fix menu via a quick pick + * + * @param folderName + * @param types + */ +export const showQuickFix = async (folderName: string, types: QuickFixActionType[]) => { + const buildItems = (): QuickFixItem[] => { + const setupToolItem = (taskId?: WizardTaskId): QuickFixItem => ({ + label: '$(tools) Customize Extension', + description: 'if you can run jest via CLI but not via the extension', + action: () => { + vscode.commands.executeCommand( + `${extensionName}.with-workspace.setup-extension`, + folderName, + taskId && { taskId } + ); + }, + }); + + const items: QuickFixItem[] = []; + for (const t of types) { + switch (t) { + case 'help': + items.push({ + label: '$(info) Help', + description: 'See troubleshooting guide', + action: () => { + vscode.commands.executeCommand( + 'vscode.open', + vscode.Uri.parse(messaging.TROUBLESHOOTING_URL) + ); + }, + }); + break; + case 'wizard': + items.push(setupToolItem()); + break; + case 'setup-cmdline': + items.push(setupToolItem('cmdLine')); + break; + case 'setup-monorepo': + items.push(setupToolItem('monorepo')); + break; + case 'disable-folder': + items.push({ + label: '$(error) Disable Extension', + description: "if you don't intend to run jest in this folder ever", + action: () => { + vscode.commands.executeCommand(`${extensionName}.with-workspace.disable`, folderName); + }, + }); + break; + case 'defer': + items.push({ + label: '$(play) Defer or Change Run Mode', + description: 'if you are not ready to run jest yet', + action: () => { + vscode.commands.executeCommand( + `${extensionName}.with-workspace.change-run-mode`, + folderName + ); + }, + }); + break; + case 'help-long-run': + items.push({ + label: '$(info) Help', + description: 'See LongRun troubleshooting guide', + action: () => { + vscode.commands.executeCommand( + 'vscode.open', + vscode.Uri.parse(messaging.LONG_RUN_TROUBLESHOOTING_URL) + ); + }, + }); + break; + } + } + return items; + }; + + const items = buildItems(); + const item = await vscode.window.showQuickPick(items, { placeHolder: 'Select a fix action' }); + item?.action(); +}; diff --git a/src/terminal-link-provider.ts b/src/terminal-link-provider.ts new file mode 100644 index 000000000..0a1e5b107 --- /dev/null +++ b/src/terminal-link-provider.ts @@ -0,0 +1,80 @@ +import * as vscode from 'vscode'; + +type ExecutableTerminalLink = vscode.TerminalLink & { data: string }; + +export const ExecutableLinkScheme = 'vscode-jest'; + +/** + * provide terminal links for commands that can be executed in the terminal. + * + * The link data is a vscode uri with the following format: + * vscode-jest:///? + * + * Note the folderName, command, and args should be encoded using encodeURIComponent + * The args should be a JSON.stringify-able object and the command should expect to receive them accordingly. + * + * example: + * vscode-jest://workspace%20name/io.orta.jest.with-workspace.setup-extension + */ +export class ExecutableTerminalLinkProvider + implements vscode.TerminalLinkProvider +{ + async handleTerminalLink(link: ExecutableTerminalLink): Promise { + try { + const uri = vscode.Uri.parse(link.data); + const folderName = decodeURIComponent(uri.authority); + const command = decodeURIComponent(uri.path).substring(1); + const args = uri.query && JSON.parse(decodeURIComponent(uri.query)); + await vscode.commands.executeCommand(command, folderName, args); + } catch (error) { + vscode.window.showErrorMessage(`Failed to handle link "${link.data}": ${error}`); + } + } + + provideTerminalLinks( + context: vscode.TerminalLinkContext, + _token: vscode.CancellationToken + ): vscode.ProviderResult { + const uriRegex = new RegExp(`${ExecutableLinkScheme}://[^\\s]+`, 'g'); + const links: ExecutableTerminalLink[] = []; + for (const match of context.line.matchAll(uriRegex)) { + if (match.index !== undefined) { + links.push({ + startIndex: match.index, + length: match[0].length, + tooltip: 'execute command', + data: match[0], + }); + } else { + // Handle the unexpected case where index is undefined + console.error('Unexpected undefined match index'); + } + } + + return links.length > 0 ? links : []; + } + register(): vscode.Disposable { + return vscode.window.registerTerminalLinkProvider(this); + } + + /** + * create a link that can be executed in the terminal + * @param folderName + * @param command + * @param arg any JSON.stringify-able object + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public executableLink(folderName: string, command: string, arg?: any): string { + const baseLink = `${ExecutableLinkScheme}://${encodeURIComponent( + folderName + )}/${encodeURIComponent(command)}`; + if (!arg) { + return baseLink; + } + const encodedQuery = encodeURIComponent(JSON.stringify(arg)); + return `${baseLink}?${encodedQuery}`; + } +} + +export const executableTerminalLinkProvider = new ExecutableTerminalLinkProvider(); diff --git a/src/test-provider/test-item-context-manager.ts b/src/test-provider/test-item-context-manager.ts index 28aeb2cea..1250b53cd 100644 --- a/src/test-provider/test-item-context-manager.ts +++ b/src/test-provider/test-item-context-manager.ts @@ -11,28 +11,21 @@ export interface SnapshotItem { itemId: string; testFullName: string; } -export type ItemContext = - | { - key: 'jest.autoRun' | 'jest.coverage'; - workspace: vscode.WorkspaceFolder; - /** the current value of the itemId */ - value: boolean; - itemIds: string[]; - } - | { - key: 'jest.editor-view-snapshot' | 'jest.editor-update-snapshot' | 'jest.workspaceRoot'; - workspace: vscode.WorkspaceFolder; - itemIds: string[]; - }; +export type ItemContext = { + key: + | 'jest.runMode' + | 'jest.editor-view-snapshot' + | 'jest.editor-update-snapshot' + | 'jest.workspaceRoot'; + workspace: vscode.WorkspaceFolder; + itemIds: string[]; +}; export type TEItemContextKey = ItemContext['key']; export class TestItemContextManager { private cache = new Map(); private wsCache: Record = {}; - private contextKey(key: TEItemContextKey, value: boolean): string { - return `${key}.${value ? 'on' : 'off'}`; - } // context are stored by key, one per workspace private updateContextCache(context: ItemContext): ItemContext[] { this.wsCache[context.workspace.name] = context.workspace; @@ -48,20 +41,7 @@ export class TestItemContextManager { public setItemContext(context: ItemContext): void { const list = this.updateContextCache(context); switch (context.key) { - case 'jest.autoRun': - case 'jest.coverage': { - //set context for both on and off - let itemIds = list - .flatMap((c) => (c.key === context.key && c.value === true ? c.itemIds : undefined)) - .filter((c) => c !== undefined); - vscode.commands.executeCommand('setContext', this.contextKey(context.key, true), itemIds); - - itemIds = list - .flatMap((c) => (c.key === context.key && c.value === false ? c.itemIds : undefined)) - .filter((c) => c !== undefined); - vscode.commands.executeCommand('setContext', this.contextKey(context.key, false), itemIds); - break; - } + case 'jest.runMode': case 'jest.editor-view-snapshot': case 'jest.editor-update-snapshot': case 'jest.workspaceRoot': { @@ -94,30 +74,19 @@ export class TestItemContextManager { } } ); - const autoRunCommands = ['test-item.auto-run.toggle-off', 'test-item.auto-run.toggle-on'].map( - (n) => - vscode.commands.registerCommand(`${extensionName}.${n}`, (testItem: vscode.TestItem) => { - const workspace = this.getItemWorkspace(testItem); - if (workspace) { - vscode.commands.executeCommand( - `${extensionName}.with-workspace.toggle-auto-run`, - workspace - ); - } - }) - ); - const coverageCommands = ['test-item.coverage.toggle-off', 'test-item.coverage.toggle-on'].map( - (n) => - vscode.commands.registerCommand(`${extensionName}.${n}`, (testItem: vscode.TestItem) => { - const workspace = this.getItemWorkspace(testItem); - if (workspace) { - vscode.commands.executeCommand( - `${extensionName}.with-workspace.toggle-coverage`, - workspace - ); - } - }) + const runModeCommand = vscode.commands.registerCommand( + `${extensionName}.test-item.run-mode.change`, + (testItem: vscode.TestItem) => { + const workspace = this.getItemWorkspace(testItem); + if (workspace) { + vscode.commands.executeCommand( + `${extensionName}.with-workspace.change-run-mode`, + workspace + ); + } + } ); + const viewSnapshotCommand = vscode.commands.registerCommand( `${extensionName}.test-item.view-snapshot`, (testItem: vscode.TestItem) => { @@ -147,13 +116,7 @@ export class TestItemContextManager { } ); - return [ - ...autoRunCommands, - ...coverageCommands, - viewSnapshotCommand, - updateSnapshotCommand, - revealOutputCommand, - ]; + return [runModeCommand, viewSnapshotCommand, updateSnapshotCommand, revealOutputCommand]; } } diff --git a/src/test-provider/test-item-data.ts b/src/test-provider/test-item-data.ts index a1bd65396..83fee0100 100644 --- a/src/test-provider/test-item-data.ts +++ b/src/test-provider/test-item-data.ts @@ -10,11 +10,12 @@ import { Logging } from '../logging'; import { TestSuitChangeEvent } from '../TestResults/test-result-events'; import { Debuggable, ItemCommand, TestItemData } from './types'; import { JestTestProviderContext, JestTestRun, JestTestRunOptions } from './test-provider-helper'; -import { JestProcessInfo, JestProcessRequest } from '../JestProcessManagement'; -import { GENERIC_ERROR, getExitErrorDef, LONG_RUNNING_TESTS } from '../errors'; +import { JestProcessInfo, JestProcessRequest, UserDataType } from '../JestProcessManagement'; +import { GENERIC_ERROR, LONG_RUNNING_TESTS, getExitErrorDef } from '../errors'; import { JestExtOutput } from '../JestExt/output-terminal'; import { tiContextManager } from './test-item-context-manager'; import { toAbsoluteRootPath } from '../helpers'; +import { runModeDescription } from '../JestExt/run-mode'; interface JestRunnable { getJestRunRequest: () => JestExtRequestType; @@ -24,12 +25,10 @@ interface WithUri { uri: vscode.Uri; } -type JestTestRunRequest = JestExtRequestType & { run: JestTestRun }; type TypedRunEvent = RunEventBase & { type: string }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const isJestTestRunRequest = (arg: any): arg is JestTestRunRequest => - arg.run instanceof JestTestRun; +const hasRunInfo = (userData?: UserDataType): userData is { run: JestTestRun } => + userData?.run instanceof JestTestRun; abstract class TestItemDataBase implements TestItemData, JestRunnable, WithUri { item!: vscode.TestItem; @@ -73,10 +72,7 @@ abstract class TestItemDataBase implements TestItemData, JestRunnable, WithUri { this.deepItemState(this.item, run.enqueued); - const process = this.context.ext.session.scheduleProcess({ - ...jestRequest, - run, - }); + const process = this.context.ext.session.scheduleProcess(jestRequest, { run }); if (!process) { const msg = `failed to schedule test for ${this.item.id}`; run.errored(this.item, new vscode.TestMessage(msg)); @@ -141,7 +137,8 @@ export class WorkspaceRoot extends TestItemDataBase { undefined, ['run'] ); - item.description = `(${this.context.ext.settings.autoRun.mode})`; + const desc = runModeDescription(this.context.ext.settings.runMode.config); + item.description = `(${desc.deferred?.label ?? desc.type.label})`; item.canResolveChildren = true; return item; @@ -337,8 +334,8 @@ export class WorkspaceRoot extends TestItemDataBase { /** get test item from jest process. If running tests from source file, will return undefined */ private getItemFromProcess = (process: JestProcessInfo): vscode.TestItem | undefined => { // the TestExplorer triggered run should already have item associated - if (isJestTestRunRequest(process.request) && process.request.run.item) { - return process.request.run.item; + if (hasRunInfo(process.userData) && process.userData.run.item) { + return process.userData.run.item; } // should only come here for autoRun processes @@ -363,8 +360,8 @@ export class WorkspaceRoot extends TestItemDataBase { private createRunForEvent = (event: TypedRunEvent): JestTestRun => { const item = this.getItemFromProcess(event.process) ?? this.item; - const name = isJestTestRunRequest(event.process.request) - ? event.process.request.run.name + const name = hasRunInfo(event.process.userData) + ? event.process.userData.run.name : `${event.type}:${event.process.id}`; const run = this.createRun({ name, @@ -377,8 +374,8 @@ export class WorkspaceRoot extends TestItemDataBase { }; /** return a valid run from process or process-run-cache. return undefined if run is closed. */ private getJestRun = (process: JestProcessInfo): JestTestRun | undefined => { - if (isJestTestRunRequest(process.request) && !process.request.run.isClosed()) { - return process.request.run; + if (hasRunInfo(process.userData) && !process.userData.run.isClosed()) { + return process.userData.run; } const run = this.cachedRun.get(process.id); if (run?.isClosed()) { @@ -436,8 +433,9 @@ export class WorkspaceRoot extends TestItemDataBase { break; } case 'end': { - if (event.error) { + if (event.error && !event.process.userData?.errorReported) { this.writer(run).write(event.error, 'error'); + event.process.userData = { ...(event.process.userData ?? {}), errorReported: true }; } this.runLog('finished'); run?.end(); @@ -446,11 +444,15 @@ export class WorkspaceRoot extends TestItemDataBase { case 'exit': { if (event.error) { run = run ?? this.createRunForEvent(event); - const type = getExitErrorDef(event.code) ?? GENERIC_ERROR; - run.write(event.error, type); + if (run.item) { run.errored(run.item, new vscode.TestMessage(event.error)); } + if (!event.process.userData?.errorReported) { + const type = getExitErrorDef(event.code) ?? GENERIC_ERROR; + run.write(event.error, type); + event.process.userData = { ...(event.process.userData ?? {}), errorReported: true }; + } } this.runLog('exited'); run?.end(); @@ -556,16 +558,12 @@ abstract class TestResultData extends TestItemDataBase { run.skipped(this.item); break; case 'KnownFail': { - if (this.context.ext.settings.testExplorer.showInlineError) { - const message = new vscode.TestMessage(result.message); - if (errorLocation) { - message.location = errorLocation; - } - - run.failed(this.item, message); - } else { - run.failed(this.item, []); + const message = new vscode.TestMessage(result.message); + if (errorLocation) { + message.location = errorLocation; } + + run.failed(this.item, message); break; } } diff --git a/src/test-provider/test-provider-helper.ts b/src/test-provider/test-provider-helper.ts index 5b3179bc1..477382ee7 100644 --- a/src/test-provider/test-provider-helper.ts +++ b/src/test-provider/test-provider-helper.ts @@ -38,6 +38,7 @@ export class JestTestProviderContext { this.testItemData.set(testItem, data); const collection = parent ? parent.children : this.controller.items; collection.add(testItem); + tagIds?.forEach((tId) => { const tag = this.getTag(tId); if (tag) { @@ -91,6 +92,45 @@ export class JestTestProviderContext { } return tag; }; + + /** + * Create a new request based on the given one, which could be based on outdated data. + * This is mainly used to support deferred mode: when the request is created during deferred mode on, it will need to be updated with new test items after existing deferred mode because the test tree has been rebuilt. + * @param request + * @returns + */ + requestFrom = (request: vscode.TestRunRequest): vscode.TestRunRequest => { + const findItem = (item: vscode.TestItem, collection: vscode.TestItemCollection) => { + let found = collection.get(item.id); + if (!found) { + collection.forEach((cItem) => { + if (!found && cItem.children) { + found = findItem(item, cItem.children); + } + }); + } + return found; + }; + const mapItems = (items?: readonly vscode.TestItem[]) => + items && + items.map((i) => { + const found = findItem(i, this.controller.items); + if (found) { + return found; + } + throw new Error(`failed to find item ${i.id}`); + }); + + const include = mapItems(request.include); + const exclude = mapItems(request.exclude); + const profile = + request.profile && this.profiles.find((p) => p.label === request.profile?.label); + if (request.profile && !profile) { + throw new Error(`failed to find profile ${request.profile.label}`); + } + + return new vscode.TestRunRequest(include, exclude, profile); + }; } export interface JestTestRunOptions { @@ -120,7 +160,7 @@ export class JestTestRun implements JestExtOutput, TestRunProtocol { private parentRun?: ParentRun; constructor( - context: JestTestProviderContext, + private context: JestTestProviderContext, parentRun: ParentRun, private options?: JestTestRunOptions ) { @@ -171,14 +211,16 @@ export class JestTestRun implements JestExtOutput, TestRunProtocol { message: vscode.TestMessage | readonly vscode.TestMessage[], duration?: number | undefined ): void => { - this.updateState((pRun) => pRun.errored(test, message, duration)); + const _msg = this.context.ext.settings.testExplorer.showInlineError ? message : []; + this.updateState((pRun) => pRun.errored(test, _msg, duration)); }; public failed = ( test: vscode.TestItem, message: vscode.TestMessage | readonly vscode.TestMessage[], duration?: number | undefined ): void => { - this.updateState((pRun) => pRun.failed(test, message, duration)); + const _msg = this.context.ext.settings.testExplorer.showInlineError ? message : []; + this.updateState((pRun) => pRun.failed(test, _msg, duration)); }; public passed = (test: vscode.TestItem, duration?: number | undefined): void => { this.updateState((pRun) => pRun.passed(test, duration)); diff --git a/src/test-provider/test-provider.ts b/src/test-provider/test-provider.ts index 89b92cd95..3c379deb7 100644 --- a/src/test-provider/test-provider.ts +++ b/src/test-provider/test-provider.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { JestTestProviderContext, JestTestRun } from './test-provider-helper'; import { WorkspaceRoot } from './test-item-data'; import { Debuggable, ItemCommand, JestExtExplorerContext, TestItemData, TestTagId } from './types'; -import { extensionId } from '../appGlobals'; +import { extensionId, extensionName } from '../appGlobals'; import { Logging } from '../logging'; import { toErrorString } from '../helpers'; import { tiContextManager } from './test-item-context-manager'; @@ -32,18 +32,9 @@ export class JestTestProvider { } private updateMenuContext() { - const autoRunOn = !this.context.ext.settings.autoRun.isOff; - const withCoverage = this.context.ext.settings.showCoverageOnLoad; tiContextManager.setItemContext({ workspace: this.context.ext.workspace, - key: 'jest.autoRun', - value: autoRunOn, - itemIds: [this.workspaceRoot.item.id], - }); - tiContextManager.setItemContext({ - workspace: this.context.ext.workspace, - key: 'jest.coverage', - value: withCoverage, + key: 'jest.runMode', itemIds: [this.workspaceRoot.item.id], }); tiContextManager.setItemContext({ @@ -65,23 +56,27 @@ export class JestTestProvider { private createProfiles = (controller: vscode.TestController): vscode.TestRunProfile[] => { const runTag = new vscode.TestTag(TestTagId.Run); const debugTag = new vscode.TestTag(TestTagId.Debug); - const profiles = [ - controller.createRunProfile( - 'run', - vscode.TestRunProfileKind.Run, - this.runTests, - true, - runTag - ), - controller.createRunProfile( - 'debug', - vscode.TestRunProfileKind.Debug, - this.runTests, - true, - debugTag - ), - ]; - return profiles; + const runProfile = controller.createRunProfile( + 'run', + vscode.TestRunProfileKind.Run, + this.runTests, + true, + runTag + ); + runProfile.configureHandler = async () => { + vscode.commands.executeCommand( + `${extensionName}.with-workspace.change-run-mode`, + this.context.ext.workspace + ); + }; + const debugProfile = controller.createRunProfile( + 'debug', + vscode.TestRunProfileKind.Debug, + this.runTests, + true, + debugTag + ); + return [runProfile, debugProfile]; }; private discoverTest = (item: vscode.TestItem | undefined): void => { @@ -138,30 +133,44 @@ export class JestTestProvider { return Promise.resolve(); }; + /** + * Runs tests for the given test run request. + * @param request - The test run request. + * @param cancelToken - Optional cancellation token. + * @param refreshRequest - Whether to rebuild the request based on current test tree. Defaults to false. + * @returns A promise that resolves when all tests have been run. + */ runTests = async ( request: vscode.TestRunRequest, - cancelToken: vscode.CancellationToken + cancelToken?: vscode.CancellationToken, + refreshRequest = false ): Promise => { - if (!request.profile) { + if (this.context.ext.settings.runMode.config.deferred) { + return vscode.commands.executeCommand( + `${extensionName}.with-workspace.exit-defer-mode`, + this.context.ext.workspace, + { request, cancelToken } + ); + } + const req = refreshRequest ? this.context.requestFrom(request) : request; + if (!req.profile) { const message = 'not supporting runRequest without profile'; this.log('error', message, request); return Promise.reject(message); } - const run = this.context.createTestRun(request, { name: this.controller.id }); - const tests = (request.include ?? this.getAllItems()).filter( - (t) => !request.exclude?.includes(t) - ); + + const run = this.context.createTestRun(req, { name: this.controller.id }); + const tests = (req.include ?? this.getAllItems()).filter((t) => !req.exclude?.includes(t)); const promises: Promise[] = []; try { for (const test of tests) { const tData = this.context.getData(test); - if (!tData || cancelToken.isCancellationRequested) { + if (!tData || cancelToken?.isCancellationRequested) { run.skipped(test); continue; } - this.log('debug', `executing profile: "${request.profile.label}" for ${test.id}...`); - if (request.profile.kind === vscode.TestRunProfileKind.Debug) { + if (req.profile.kind === vscode.TestRunProfileKind.Debug) { await this.debugTest(tData, run); } else { promises.push( @@ -182,12 +191,12 @@ export class JestTestProvider { ); } } + + await Promise.allSettled(promises); } catch (e) { - const msg = `failed to execute profile "${request.profile.label}": ${JSON.stringify(e)}`; + const msg = `failed to execute profile "${req.profile.label}": ${e}`; run.write(msg, 'error'); } - - await Promise.allSettled(promises); run.end(); }; diff --git a/src/types.ts b/src/types.ts index 2e904149b..70a6504f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,3 +10,8 @@ export interface TestStats { unknown: number; } export type TestStatsCategory = keyof TestStats; + +export interface TestExplorerRunRequest { + request: vscode.TestRunRequest; + token: vscode.CancellationToken; +} diff --git a/src/virtual-workspace-folder.ts b/src/virtual-workspace-folder.ts index dc457033a..b541c7439 100644 --- a/src/virtual-workspace-folder.ts +++ b/src/virtual-workspace-folder.ts @@ -48,12 +48,12 @@ export class VirtualFolderBasedCache { if (isVirtualWorkspaceFolder(workspaceFolder)) { // delete the virtual folder from the actual folder let items = this.byActualFolderName[workspaceFolder.actualWorkspaceFolder.name]; - items = items.filter((i) => i.workspaceFolder.name !== workspaceFolder.name); + items = items?.filter((i) => i.workspaceFolder.name !== workspaceFolder.name); this.byActualFolderName[workspaceFolder.actualWorkspaceFolder.name] = items; } else { // delete all the virtual folders under the actual folder const items = this.byActualFolderName[workspaceFolder.name]; - items.forEach((item) => delete this.byFolderName[item.workspaceFolder.name]); + items?.forEach((item) => delete this.byFolderName[item.workspaceFolder.name]); delete this.byActualFolderName[workspaceFolder.name]; } } diff --git a/syntaxes/jestRunModeSchema.json b/syntaxes/jestRunModeSchema.json new file mode 100644 index 000000000..2edd8afd0 --- /dev/null +++ b/syntaxes/jestRunModeSchema.json @@ -0,0 +1,84 @@ +{ + "title": "Jest Run Mode Setting", + "markdownDescription": "Schema for validating the jest.runMode setting in vscode. See details in [runMode](https://github.com/jest-community/vscode-jest#runmode)", + "type": "object", + "properties": { + "jest.runMode": { + "markdownDescription": "Control when and how jest tests should be run. See details in [runMode](https://github.com/jest-community/vscode-jest#runmode)", + "default": "watch", + "oneOf": [ + { + "type": "string", + "enum": [ + "watch", + "on-demand", + "on-save", + "deferred" + ], + "markdownDescription": "A predefined Jest run mode. See details in [runMode](https://github.com/jest-community/vscode-jest#runmode)" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "watch", + "on-demand", + "on-save" + ], + "description": "Specifies the jest run mode type." + }, + "runAllTestsOnStartup": { + "type": "boolean", + "description": "Specifies whether to run all tests on startup." + }, + "coverage": { + "type": "boolean", + "description": "Specifies whether to collect and report coverage information." + }, + "revealOutput": { + "type": "string", + "enum": [ + "on-run", + "on-exec-error", + "on-demand" + ], + "description": "Determines when to reveal the test run output." + }, + "deferred": { + "type": "boolean", + "description": "Specifies whether the run mode is deferred." + } + }, + "required": ["type"], + "additionalProperties": true, + "if": { + "properties": { + "type": { + "const": "on-save" + } + } + }, + "then": { + "properties": { + "testFileOnly": { + "type": "boolean", + "description": "if true, will run tests only when saving test files." + } + }, + "additionalProperties":true + }, + "else": { + "not": { + "required": ["testFileOnly"] + }, + "errorMessage": "The property 'testFileOnly' should only be present when 'type' is 'on-save'.", + "additionalProperties":true + }, + "markdownDescription": "A detailed runMode configuration. See details in [runMode](https://github.com/jest-community/vscode-jest#runmode)" + } + ] + } + } +} diff --git a/tests/Coverage/CoverageOverlay.test.ts b/tests/Coverage/CoverageOverlay.test.ts index 30375e921..56e30a279 100644 --- a/tests/Coverage/CoverageOverlay.test.ts +++ b/tests/Coverage/CoverageOverlay.test.ts @@ -66,53 +66,6 @@ describe('CoverageOverlay', () => { expect(sut.enabled).toBe(expected); }); }); - - describe('set', () => { - it('should set the overlay visibility', () => { - const expected = true; - const sut = new CoverageOverlay(null, coverageMapProvider, !expected); - sut.updateVisibleEditors = jest.fn(); - sut.enabled = expected; - - expect(sut.enabled).toBe(expected); - }); - - it('should refresh the overlays in visible editors', () => { - const sut = new CoverageOverlay(null, coverageMapProvider); - sut.updateVisibleEditors = jest.fn(); - sut.enabled = true; - - expect(sut.updateVisibleEditors).toHaveBeenCalled(); - }); - }); - }); - - describe('toggleVisibility()', () => { - it('should enable the overlay when disabled', () => { - const enabled = false; - const sut = new CoverageOverlay(null, coverageMapProvider, enabled); - sut.updateVisibleEditors = jest.fn(); - sut.toggleVisibility(); - - expect(sut.enabled).toBe(true); - }); - - it('should disable the overlay when enabled', () => { - const enabled = true; - const sut = new CoverageOverlay(null, coverageMapProvider, enabled); - sut.updateVisibleEditors = jest.fn(); - sut.toggleVisibility(); - - expect(sut.enabled).toBe(false); - }); - - it('should refresh the overlays in visible editors', () => { - const sut = new CoverageOverlay(null, coverageMapProvider); - sut.updateVisibleEditors = jest.fn(); - sut.toggleVisibility(); - - expect(sut.updateVisibleEditors).toHaveBeenCalled(); - }); }); describe('updateVisibleEditors()', () => { diff --git a/tests/JestExt/auto-run.test.ts b/tests/JestExt/auto-run.test.ts deleted file mode 100644 index f36caf3a6..000000000 --- a/tests/JestExt/auto-run.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -jest.unmock('../../src/JestExt/auto-run'); -jest.unmock('../test-helper'); - -import { AutoRun } from '../../src/JestExt/auto-run'; - -describe('AutoRun', () => { - it.each` - case | pluginSettings | expectedConfig - ${4} | ${{ autoRun: { watch: false } }} | ${{ watch: false }} - ${5} | ${{ autoRun: 'default' }} | ${{ watch: true }} - ${6} | ${{ autoRun: 'off' }} | ${{ watch: false }} - ${7} | ${{ autoRun: 'watch' }} | ${{ watch: true }} - ${8} | ${{ autoRun: 'legacy' }} | ${{ watch: true, onStartup: ['all-tests'] }} - ${9} | ${{ autoRun: 'on-save' }} | ${{ watch: false, onSave: 'test-src-file' }} - ${10} | ${{ autoRun: 'bad-string' }} | ${{ watch: true }} - ${11} | ${{ autoRun: { watch: false, onStartup: ['all-tests'] } }} | ${{ watch: false, onStartup: ['all-tests'] }} - `('construct AutoRun from user settings: $case', ({ pluginSettings, expectedConfig }) => { - const { autoRun } = pluginSettings; - const ar = new AutoRun(autoRun); - expect(ar.config).toEqual(expectedConfig); - }); - - it.each` - autoRunConfig | mode - ${'off'} | ${'auto-run-off'} - ${{}} | ${'auto-run-off'} - ${{ watch: true }} | ${'auto-run-watch'} - ${{ watch: false }} | ${'auto-run-off'} - ${{ watch: false, onSave: undefined }} | ${'auto-run-off'} - ${{ watch: false, onSave: 'test-file' }} | ${'auto-run-on-save-test'} - ${{ watch: false, onSave: 'test-src-file' }} | ${'auto-run-on-save'} - `('$autoRunConfig => $mode', ({ autoRunConfig, mode }) => { - const autoRun = new AutoRun(autoRunConfig); - expect(autoRun.mode).toEqual(mode); - }); - it.each` - autoRunConfig | accessor - ${{ watch: false }} | ${{ isOff: true }} - ${{ watch: true }} | ${{ isOff: false, isWatch: true }} - ${{ watch: true, onStartup: ['all-tests'] }} | ${{ isOff: false, isWatch: true, onStartup: ['all-tests'] }} - ${{ watch: false, onStartup: ['all-tests'] }} | ${{ isOff: false, isWatch: false, onStartup: ['all-tests'] }} - ${{ watch: false, onStartup: ['all-tests'], onSave: 'test-file' }} | ${{ isOff: false, isWatch: false, onStartup: ['all-tests'], onSave: 'test-file' }} - ${{ watch: false, onSave: 'test-src-file' }} | ${{ isOff: false, isWatch: false, onSave: 'test-src-file' }} - `('check accessor for config: $autoRunConfig', ({ autoRunConfig, accessor }) => { - const autoRun = new AutoRun(autoRunConfig); - expect(autoRun.isOff).toEqual(accessor.isOff); - expect(autoRun.isWatch).toEqual(accessor.isWatch ?? false); - expect(autoRun.onStartup).toEqual(accessor.onStartup); - expect(autoRun.onSave).toEqual(accessor.onSave); - expect(autoRun.config).toEqual(autoRunConfig); - }); - it('can toggle off at run time', () => { - const original: any = { watch: true, onStartup: ['all-tests'] }; - const autoRun = new AutoRun(original); - expect(autoRun.isOff).toBe(false); - expect(autoRun.config).toEqual(original); - - autoRun.toggle(); - expect(autoRun.isOff).toBe(true); - expect(autoRun.config).toEqual({ watch: false }); - - autoRun.toggle(); - expect(autoRun.isOff).toBe(false); - expect(autoRun.config).toEqual(original); - }); - it('for original setting "off", toggle on means to switch to "on-save" mode', () => { - const original: any = { watch: false }; - const autoRun = new AutoRun(original); - expect(autoRun.isOff).toBe(true); - expect(autoRun.config).toEqual(original); - - autoRun.toggle(); - expect(autoRun.isOff).toBe(false); - expect(autoRun.config).toEqual({ watch: false, onSave: 'test-src-file' }); - - autoRun.toggle(); - expect(autoRun.isOff).toBe(true); - expect(autoRun.config).toEqual(original); - }); -}); diff --git a/tests/JestExt/core.test.ts b/tests/JestExt/core.test.ts index 678169f61..60366fd67 100644 --- a/tests/JestExt/core.test.ts +++ b/tests/JestExt/core.test.ts @@ -1,7 +1,7 @@ jest.unmock('events'); jest.unmock('../../src/JestExt/core'); jest.unmock('../../src/JestExt/helper'); -jest.unmock('../../src/JestExt/auto-run'); +jest.unmock('../../src/JestExt/run-mode'); jest.unmock('../../src/appGlobals'); jest.unmock('../../src/errors'); jest.unmock('../test-helper'); @@ -30,7 +30,7 @@ jest.mock('../../src/workspace-manager', () => ({ import * as vscode from 'vscode'; import { JestExt } from '../../src/JestExt/core'; -import { AutoRun } from '../../src/JestExt/auto-run'; +import { RunMode } from '../../src/JestExt/run-mode'; import { createProcessSession } from '../../src/JestExt/process-session'; import { updateCurrentDiagnostics, updateDiagnostics } from '../../src/diagnostics'; import { CoverageMapProvider } from '../../src/Coverage'; @@ -38,7 +38,7 @@ import * as helper from '../../src/helpers'; import { TestIdentifier, resultsWithLowerCaseWindowsDriveLetters } from '../../src/TestResults'; import * as messaging from '../../src/messaging'; import { PluginResourceSettings } from '../../src/Settings'; -import * as extHelper from '../..//src/JestExt/helper'; +import * as extHelper from '../../src/JestExt/helper'; import { workspaceLogging } from '../../src/logging'; import { ProjectWorkspace } from 'jest-editor-support'; import { @@ -48,13 +48,13 @@ import { mockWorkspaceLogging, } from '../test-helper'; import { JestTestProvider } from '../../src/test-provider'; -import { MessageAction } from '../../src/messaging'; -import { addFolderToDisabledWorkspaceFolders } from '../../src/extension-manager'; import { JestOutputTerminal } from '../../src/JestExt/output-terminal'; import { RunShell } from '../../src/JestExt/run-shell'; import * as errors from '../../src/errors'; import { ItemCommand } from '../../src/test-provider/types'; import { TestResultProvider } from '../../src/TestResults'; +import { executableTerminalLinkProvider } from '../../src/terminal-link-provider'; +import { updateSetting } from '../../src/Settings'; /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "expectItTakesNoAction"] }] */ const mockHelpers = helper as jest.Mocked; @@ -63,8 +63,8 @@ const mockOutputTerminal = { write: jest.fn(), show: jest.fn(), close: jest.fn(), - reveal: jest.fn(), dispose: jest.fn(), + enable: jest.fn(), }; const EmptySortedResult = { @@ -123,6 +123,15 @@ describe('JestExt', () => { const mockTestProvider: any = { dispose: jest.fn(), runItemCommand: jest.fn(), + runTests: jest.fn(), + }; + + const mockListTestFiles = (files: string[] = [], error?: string, exitCode = 0) => { + mockProcessSession.scheduleProcess.mockImplementation((request) => { + if (request.type === 'list-test-files') { + return request.onResult(files, error, exitCode); + } + }); }; beforeEach(() => { @@ -131,7 +140,7 @@ describe('JestExt', () => { mockSettings = { debugCodeLens: {}, testExplorer: { enabled: true }, - autoRun: { watch: true }, + runMode: new RunMode(), jestCommandLine: 'jest', }; getConfiguration.mockReturnValue({}); @@ -139,12 +148,13 @@ describe('JestExt', () => { (createProcessSession as jest.Mocked).mockReturnValue(mockProcessSession); (ProjectWorkspace as jest.Mocked).mockImplementation(mockProjectWorkspace); (workspaceLogging as jest.Mocked).mockImplementation(mockWorkspaceLogging); - (JestTestProvider as jest.Mocked).mockImplementation(() => mockTestProvider); + (JestTestProvider as jest.Mocked).mockImplementation(() => ({ ...mockTestProvider })); (JestOutputTerminal as jest.Mocked).mockImplementation(() => mockOutputTerminal); (vscode.EventEmitter as jest.Mocked) = jest.fn().mockImplementation(() => { return { fire: jest.fn(), event: jest.fn(), dispose: jest.fn() }; }); (RunShell as jest.Mocked).mockImplementation(() => ({ toSetting: jest.fn() })); + mockListTestFiles(); }); describe('debugTests()', () => { @@ -603,23 +613,22 @@ describe('JestExt', () => { describe('onDidSaveTextDocument', () => { describe('should handle onSave run', () => { it.each` - runConfig | languageId | isTestFile | shouldSchedule | isDirty - ${'off'} | ${'javascript'} | ${'yes'} | ${false} | ${true} - ${{ watch: true }} | ${'javascript'} | ${'yes'} | ${false} | ${false} - ${{ watch: false }} | ${'javascript'} | ${'yes'} | ${false} | ${true} - ${{ watch: false, onSave: 'test-src-file' }} | ${'javascript'} | ${'no'} | ${true} | ${false} - ${{ watch: false, onSave: 'test-src-file' }} | ${'javascript'} | ${'unknown'} | ${true} | ${false} - ${{ watch: false, onSave: 'test-src-file' }} | ${'javascript'} | ${'yes'} | ${true} | ${false} - ${{ watch: false, onSave: 'test-src-file' }} | ${'json'} | ${'no'} | ${false} | ${false} - ${{ watch: false, onSave: 'test-file' }} | ${'javascript'} | ${'no'} | ${false} | ${true} - ${{ watch: false, onSave: 'test-file' }} | ${'javascript'} | ${'unknown'} | ${true} | ${false} - ${{ watch: false, onSave: 'test-file' }} | ${'javascript'} | ${'yes'} | ${true} | ${false} - ${{ watch: false, onSave: 'test-file' }} | ${'javascript'} | ${'unknown'} | ${true} | ${false} - ${{ watch: false, onSave: 'test-file' }} | ${'json'} | ${'unknown'} | ${false} | ${false} + runConfig | languageId | isTestFile | shouldSchedule | isDirty + ${'on-demand'} | ${'javascript'} | ${'yes'} | ${false} | ${false} + ${'watch'} | ${'javascript'} | ${'yes'} | ${false} | ${false} + ${'on-save'} | ${'javascript'} | ${'no'} | ${true} | ${false} + ${'on-save'} | ${'javascript'} | ${'unknown'} | ${true} | ${false} + ${'on-save'} | ${'javascript'} | ${'yes'} | ${true} | ${false} + ${'on-save'} | ${'json'} | ${'no'} | ${false} | ${false} + ${{ type: 'on-save', testFileOnly: true }} | ${'javascript'} | ${'no'} | ${false} | ${true} + ${{ type: 'on-save', testFileOnly: true }} | ${'javascript'} | ${'unknown'} | ${true} | ${false} + ${{ type: 'on-save', testFileOnly: true }} | ${'javascript'} | ${'yes'} | ${true} | ${false} + ${{ type: 'on-save', testFileOnly: true }} | ${'javascript'} | ${'unknown'} | ${true} | ${false} + ${{ type: 'on-save', testFileOnly: true }} | ${'json'} | ${'unknown'} | ${false} | ${false} `( - 'with autoRun: $runConfig $languageId $isTestFile => $shouldSchedule, $isDirty', + 'with runMode: $runConfig $languageId $isTestFile => $shouldSchedule, $isDirty', ({ runConfig, languageId, isTestFile, shouldSchedule, isDirty }) => { - const sut: any = newJestExt({ settings: { autoRun: new AutoRun(runConfig) } }); + const sut: any = newJestExt({ settings: { runMode: new RunMode(runConfig) } }); const fileName = '/a/file;'; const document: any = { uri: { scheme: 'file' }, @@ -652,19 +661,21 @@ describe('JestExt', () => { ); }); }); - describe('toggleCoverageOverlay()', () => { + describe('toggleCoverage()', () => { it('should toggle the coverage overlay visibility', () => { - const sut = newJestExt(); + const runMode = new RunMode('on-demand'); + const sut = newJestExt({ settings: { runMode } }); sut.triggerUpdateSettings = jest.fn(); - sut.toggleCoverageOverlay(); + sut.toggleCoverage(); - expect(sut.coverageOverlay.toggleVisibility).toHaveBeenCalled(); + expect(runMode.config.coverage).toBe(true); expect(sut.triggerUpdateSettings).toHaveBeenCalled(); }); it('overrides showCoverageOnLoad settings', async () => { + const runMode = new RunMode({ type: 'watch', coverage: true }); const settings = { - showCoverageOnLoad: true, + runMode, shell: { toSetting: jest.fn() }, } as any; const sut = newJestExt({ settings }); @@ -673,14 +684,12 @@ describe('JestExt', () => { let runnerWorkspace = createRunnerWorkspace(); expect(runnerWorkspace.collectCoverage).toBe(true); - sut.coverageOverlay.enabled = false; - await sut.toggleCoverageOverlay(); + await sut.toggleCoverage(); - const { createRunnerWorkspace: f2, settings: settings2 } = ( - createProcessSession as jest.Mocked - ).mock.calls[1][0]; + const { createRunnerWorkspace: f2 } = (createProcessSession as jest.Mocked).mock + .calls[1][0]; runnerWorkspace = f2(); - expect(settings2.showCoverageOnLoad).toBe(false); + expect(runMode.config.coverage).toBe(false); expect(runnerWorkspace.collectCoverage).toBe(false); }); }); @@ -815,7 +824,7 @@ describe('JestExt', () => { expect.objectContaining({ session: mockProcessSession }) ); }); - it('will refresh the active editor, if any', async () => { + it('will refresh the visible editors, if any', async () => { const sut = createJestExt(); const spy = jest.spyOn(sut, 'triggerUpdateActiveEditor').mockImplementation(() => {}); @@ -825,17 +834,21 @@ describe('JestExt', () => { expect(spy).not.toHaveBeenCalled(); // with activeTextEditor - (vscode.window.activeTextEditor as any) = { - document: { uri: 'whatever' }, - }; + (vscode.window.visibleTextEditors as any) = [ + { + document: { uri: 'whatever' }, + }, + ]; await sut.startSession(); expect(spy).toHaveBeenCalled(); }); - it('if failed to start session, show error', async () => { + it('if failed to start session, show error message and quick fix link', async () => { mockProcessSession.start.mockReturnValueOnce(Promise.reject('forced error')); const sut = createJestExt(); await sut.startSession(); - expect(messaging.systemErrorMessage).toHaveBeenCalled(); + + expect(mockOutputTerminal.write).toHaveBeenCalledWith(expect.anything(), 'error'); + expect(executableTerminalLinkProvider.executableLink).toHaveBeenCalled(); }); it('dispose existing jestProvider before creating new one', async () => { expect.hasAssertions(); @@ -904,15 +917,14 @@ describe('JestExt', () => { it('will update statusBar', async () => { expect.hasAssertions(); - const sut = newJestExt(); - sut.coverageOverlay.enabled = true; + const runMode = new RunMode({ type: 'on-demand', coverage: true }); + const sut = newJestExt({ settings: { runMode } }); await sut.startSession(); - expect(sbUpdateMock).toHaveBeenCalledWith( - expect.objectContaining({ - state: 'initial', - mode: expect.arrayContaining(['coverage']), - }) - ); + const update = sbUpdateMock.mock.calls.find( + (call) => call[0].state === 'initial' && call[0].mode + )[0]; + expect(update.state).toEqual('initial'); + expect(update.mode.config.coverage).toEqual(true); }); }); describe('stopSession', () => { @@ -936,11 +948,12 @@ describe('JestExt', () => { await sut.stopSession(); expect(sbUpdateMock).toHaveBeenCalledWith({ state: 'stopped' }); }); - it('if failed to stop session, show error', async () => { + it('if failed to stop session, display error and quick fix link', async () => { mockProcessSession.stop.mockReturnValueOnce(Promise.reject('forced error')); const sut = createJestExt(); await sut.stopSession(); - expect(messaging.systemErrorMessage).toHaveBeenCalled(); + expect(mockOutputTerminal.write).toHaveBeenCalledWith(expect.anything(), 'error'); + expect(executableTerminalLinkProvider.executableLink).toHaveBeenCalled(); }); }); }); @@ -967,12 +980,12 @@ describe('JestExt', () => { beforeEach(() => { mockProcessSession.scheduleProcess.mockReturnValueOnce(scheduleProcess); }); - it('can run all test for the workspace', () => { + it('can run all test for the workspace', async () => { const sut = newJestExt(); const dirtyFiles: any = sut['dirtyFiles']; dirtyFiles.clear = jest.fn(); - sut.runAllTests(); + await sut.runAllTests(); expect(mockProcessSession.scheduleProcess).toHaveBeenCalledWith({ type: 'all-tests' }); if (scheduleProcess) { expect(dirtyFiles.clear).toHaveBeenCalled(); @@ -980,7 +993,7 @@ describe('JestExt', () => { expect(dirtyFiles.clear).not.toHaveBeenCalled(); } }); - it('can run all test for the given editor', () => { + it('can run all test for the given editor', async () => { const sut = newJestExt(); const dirtyFiles: any = sut['dirtyFiles']; @@ -988,7 +1001,7 @@ describe('JestExt', () => { const editor: any = { document: { fileName: 'whatever' } }; - sut.runAllTests(editor); + await sut.runAllTests(editor); expect(mockProcessSession.scheduleProcess).toHaveBeenCalledWith({ type: 'by-file', testFileName: editor.document.fileName, @@ -1008,13 +1021,13 @@ describe('JestExt', () => { ${'unknown'} | ${true} `( 'treat unknown as notTestFile: isTestFile=$isTestFile => notTestFile=$notTestFile', - ({ isTestFile, notTestFile }) => { + async ({ isTestFile, notTestFile }) => { const sut = newJestExt(); const editor: any = { document: { fileName: 'whatever' } }; (sut.testResultProvider.isTestFile as jest.Mocked).mockReturnValueOnce(isTestFile); - sut.runAllTests(editor); + await sut.runAllTests(editor); if (notTestFile) { expect(mockProcessSession.scheduleProcess).toHaveBeenCalledWith({ type: 'by-file', @@ -1061,7 +1074,7 @@ describe('JestExt', () => { expect(createProcessSession).toHaveBeenCalledTimes(1); const settings: any = { debugMode: true, - autoRun: { watch: true }, + runMode: new RunMode('watch'), jestCommandLine: 'jest', }; await jestExt.triggerUpdateSettings(settings); @@ -1192,42 +1205,22 @@ describe('JestExt', () => { onRunEvent({ type: 'exit', process }); expect(sbUpdateMock).toHaveBeenCalledWith({ state: 'done' }); }); - it('if error: status bar stopped and show error', () => { - onRunEvent({ type: 'exit', error: 'something is wrong', process }); - expect(sbUpdateMock).toHaveBeenCalledWith({ state: 'exec-error' }); - expect(messaging.systemErrorMessage).toHaveBeenCalledWith( - 'something is wrong', - { action: expect.any(Function), title: 'Fix' }, - { action: expect.any(Function), title: 'Help' } - ); - const setupAction: MessageAction = (messaging.systemErrorMessage as jest.Mocked).mock - .calls[0][1]; - - setupAction.action(); - - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - expect.stringContaining('setup-extension'), - expect.objectContaining({ workspace: workspaceFolder }) - ); - }); - it('if error: status bar stopped and show error with ignore folder button', () => { - (vscode.workspace.workspaceFolders as any) = ['testfolder1', 'testfolder']; - - onRunEvent({ type: 'exit', error: 'something is wrong', process }); - expect(sbUpdateMock).toHaveBeenCalledWith({ state: 'exec-error' }); - expect(messaging.systemErrorMessage).toHaveBeenCalledWith( - '(test-folder) something is wrong', - { action: expect.any(Function), title: 'Fix' }, - { action: expect.any(Function), title: 'Ignore Folder' }, - { action: expect.any(Function), title: 'Help' } - ); - - const ignoreAction: MessageAction = (messaging.systemErrorMessage as jest.Mocked) - .mock.calls[0][2]; - - ignoreAction.action(); - - expect(addFolderToDisabledWorkspaceFolders).toHaveBeenCalledWith('test-folder'); + describe('if error', () => { + it('if error: status bar stopped and display quick-fix link in output', () => { + onRunEvent({ type: 'exit', error: 'something is wrong', process }); + expect(sbUpdateMock).toHaveBeenCalledWith({ state: 'exec-error' }); + expect(executableTerminalLinkProvider.executableLink).toHaveBeenCalled(); + expect(mockOutputTerminal.write).toHaveBeenCalledWith(expect.anything(), 'info'); + expect(process.userData?.errorReported).toEqual(true); + }); + it('will not report error if already reported', () => { + mockOutputTerminal.write.mockClear(); + process.userData = { errorReported: true }; + onRunEvent({ type: 'exit', error: 'something is wrong', process }); + expect(sbUpdateMock).toHaveBeenCalledWith({ state: 'exec-error' }); + expect(executableTerminalLinkProvider.executableLink).not.toHaveBeenCalled(); + expect(mockOutputTerminal.write).not.toHaveBeenCalled(); + }); }); }); it.each` @@ -1235,13 +1228,13 @@ describe('JestExt', () => { ${undefined} ${17} `( - 'long-run event with $numTotalTestSuites numTotalTestSuites triggers system warning', + 'long-run event with $numTotalTestSuites numTotalTestSuites output warnings', ({ numTotalTestSuites }) => { + mockOutputTerminal.write.mockClear(); process = { ...process, request: { type: 'all-tests' } }; onRunEvent({ type: 'long-run', numTotalTestSuites, threshold: 60000, process }); - expect(messaging.systemWarningMessage).toHaveBeenCalledTimes(1); - const msg = (messaging.systemWarningMessage as jest.Mocked).mock.calls[0][0]; - expect(msg).toContain('all-tests'); + expect(executableTerminalLinkProvider.executableLink).toHaveBeenCalled(); + const msg = mockOutputTerminal.write.mock.calls[0][0]; if (numTotalTestSuites) { expect(msg).toContain(`${numTotalTestSuites} suites`); } else { @@ -1283,15 +1276,37 @@ describe('JestExt', () => { sut.showOutput(); expect(mockOutputTerminal.show).toHaveBeenCalled(); }); - it('toggleAutoRun will trigger autoRun to toggle runtime config', () => { - const autoRun = new AutoRun('watch'); - const sut: any = newJestExt({ settings: { autoRun } }); - expect(autoRun.isWatch).toBeTruthy(); - expect(autoRun.isOff).toBeFalsy(); - - sut.toggleAutoRun(); - expect(autoRun.isWatch).toBeFalsy(); - expect(autoRun.isOff).toBeTruthy(); + describe('can change runMode', () => { + let sut, runMode, quickSwitchSpy, triggerUpdateSettingsSpy; + beforeEach(() => { + runMode = new RunMode('watch'); + + quickSwitchSpy = jest.spyOn(runMode, 'quickSwitch'); + + sut = newJestExt({ settings: { runMode } }); + triggerUpdateSettingsSpy = jest.spyOn(sut, 'triggerUpdateSettings'); + }); + it('no op if runMode did not change', async () => { + quickSwitchSpy.mockImplementation(() => { + return undefined; + }); + + await sut.changeRunMode(); + expect(quickSwitchSpy).toHaveBeenCalled(); + expect(triggerUpdateSettingsSpy).not.toHaveBeenCalled(); + }); + it('restart session if runMode changed', async () => { + const runMode2 = new RunMode('on-demand'); + quickSwitchSpy.mockImplementation(() => { + return runMode2; + }); + + await sut.changeRunMode(); + expect(quickSwitchSpy).toHaveBeenCalled(); + expect(triggerUpdateSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ runMode: runMode2 }) + ); + }); }); describe('enableLoginShell', () => { let mockShell; @@ -1331,7 +1346,7 @@ describe('JestExt', () => { const jestExt = newJestExt(); await jestExt.startSession(); const testItem: any = {}; - jestExt.runItemCommand(testItem, ItemCommand.updateSnapshot); + await jestExt.runItemCommand(testItem, ItemCommand.updateSnapshot); expect(mockTestProvider.runItemCommand).toHaveBeenCalledWith( testItem, ItemCommand.updateSnapshot @@ -1393,11 +1408,11 @@ describe('JestExt', () => { }); describe('when detection failed in a monorepo', () => { it.each` - case | folders | actionId - ${'no workspaceFolders'} | ${undefined} | ${'cmdLine'} - ${'single-root'} | ${[ws1]} | ${'monorepo'} - ${'multi-root'} | ${[ws1, ws2]} | ${'cmdLine'} - `('$case', async ({ folders, actionId }) => { + case | folders | actionType + ${'no workspaceFolders'} | ${undefined} | ${'setup-cmdline'} + ${'single-root'} | ${[ws1]} | ${'setup-monorepo'} + ${'multi-root'} | ${[ws1, ws2]} | ${'setup-cmdline'} + `('$case', async ({ folders, actionType }) => { (vscode.workspace as any).workspaceFolders = folders ? folders.map(() => makeWorkspaceFolder('whatever')) : undefined; @@ -1410,44 +1425,44 @@ describe('JestExt', () => { updateSettingSpy.mockReturnValueOnce(Promise.resolve()); await expect(jestExt.validateJestCommandLine()).resolves.toEqual('fail'); - expect(messaging.systemErrorMessage).toHaveBeenCalledTimes(1); - const fixAction = (messaging.systemErrorMessage as jest.Mocked).mock.calls[0][1]; - expect(fixAction.title).toEqual('Fix'); - fixAction.action(); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - expect.stringContaining('setup-extension'), - expect.objectContaining({ - taskId: actionId, - }) - ); + + const [wsName, command, actionTypes] = ( + executableTerminalLinkProvider.executableLink as jest.Mocked + ).mock.calls[0]; + expect(wsName).toEqual(jestExt.workspaceFolder.name); + expect(command).toContain('show-quick-fix'); + expect(actionTypes).toContain(actionType); }); }); }); describe('autoRevealOutput', () => { it.each` - autoRevealOutput | shouldReveal | revealOnError - ${'on-run'} | ${true} | ${true} + revealOutput | shouldReveal | revealOnError + ${'on-run'} | ${true} | ${false} ${'on-exec-error'} | ${false} | ${true} - ${'off'} | ${false} | ${false} + ${'on-demand'} | ${false} | ${false} `( - 'autoRevealOutput = $autoRevealOutput, shouldReveal = $shouldReveal', - ({ autoRevealOutput, shouldReveal, revealOnError }) => { - const sut = newJestExt({ settings: { autoRevealOutput } }); + 'revealOutput = $revealOutput, shouldReveal = $shouldReveal', + ({ revealOutput, shouldReveal, revealOnError }) => { + const runMode = new RunMode({ type: 'watch', revealOutput }); + const sut = newJestExt({ settings: { runMode } }); const onRunEvent = (sut.events.onRunEvent.event as jest.Mocked).mock.calls[0][0]; const process = { id: 'a process id', request: { type: 'watch' } }; onRunEvent({ type: 'start', process }); if (shouldReveal) { - expect(mockOutputTerminal.reveal).toHaveBeenCalled(); + expect(mockOutputTerminal.enable).toHaveBeenCalled(); } else { - expect(mockOutputTerminal.reveal).not.toHaveBeenCalled(); + expect(mockOutputTerminal.enable).not.toHaveBeenCalled(); } expect(mockOutputTerminal.revealOnError).toEqual(revealOnError); } ); it('when setting changed, output setting will change accordingly', () => { - const sut = newJestExt({ settings: { autoRevealOutput: 'on-exec-error' } }); + const runMode = new RunMode({ type: 'watch', revealOutput: 'on-exec-error' }); + const sut = newJestExt({ settings: { runMode } }); expect(mockOutputTerminal.revealOnError).toEqual(true); - sut.triggerUpdateSettings({ autoRevealOutput: 'off' } as any); + const runMode2 = new RunMode({ type: 'watch', revealOutput: 'on-demand' }); + sut.triggerUpdateSettings({ runMode: runMode2 } as any); expect(mockOutputTerminal.revealOnError).toEqual(false); expect(mockOutputTerminal.close).toHaveBeenCalled(); }); @@ -1477,6 +1492,7 @@ describe('JestExt', () => { const settings: any = { debugMode: true, parserPluginOptions, + runMode: new RunMode('watch'), }; await jestExt.triggerUpdateSettings(settings); expect(jestExt.testResultProvider.options).toEqual({ @@ -1486,7 +1502,7 @@ describe('JestExt', () => { }); }); describe('virtual folder related', () => { - it('added name and workspaceFolder properties}', () => { + it('added name and workspaceFolder properties', () => { const jestExt = newJestExt(); expect(jestExt.name).toEqual(workspaceFolder.name); expect(jestExt.workspaceFolder).toEqual(workspaceFolder); @@ -1495,4 +1511,181 @@ describe('JestExt', () => { expect(() => newJestExt({ settings: { enable: false } })).toThrow('Jest is disabled'); }); }); + it('setupExtensionForFolder pass extension info via executeCommand', () => { + const jestExt = newJestExt(); + jestExt.setupExtensionForFolder({ taskId: 'cmdLine' }); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + expect.stringContaining(`setup-extension`), + expect.objectContaining({ + workspace: jestExt.workspaceFolder, + taskId: 'cmdLine', + }) + ); + }); + describe('defer runMode', () => { + let doc: any; + let editor: any; + beforeEach(() => { + doc = { uri: 'whatever', languageId: 'javascript' }; + editor = { document: doc }; + vscode.window.visibleTextEditors = [editor]; + }); + it('will still create testProvider and parse test blocks while skipping the rest', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'watch', deferred: true }); + const jestExt = newJestExt({ settings: { runMode } }); + + const validateJestCommandLineSpy = jest.spyOn(jestExt, 'validateJestCommandLine'); + + jestExt.triggerUpdateActiveEditor = jest.fn(); + + await jestExt.startSession(); + + expect(JestTestProvider).toHaveBeenCalledTimes(1); + expect(jestExt.triggerUpdateActiveEditor).toHaveBeenCalledWith(editor); + + expect(validateJestCommandLineSpy).not.toHaveBeenCalled(); + expect(mockProcessSession.scheduleProcess).not.toHaveBeenCalled(); + }); + it('will not show output even in exec-error', async () => { + expect.hasAssertions(); + + let runMode = new RunMode({ type: 'watch', revealOutput: 'on-exec-error', deferred: true }); + newJestExt({ settings: { runMode } }); + expect(mockOutputTerminal.revealOnError).toEqual(false); + + // while in non-deferred mode, the revealOnError will be true + runMode = new RunMode({ type: 'watch', revealOutput: 'on-exec-error', deferred: false }); + newJestExt({ settings: { runMode } }); + expect(mockOutputTerminal.revealOnError).toEqual(true); + }); + it('will not do any auto-run for on-save mode either', async () => { + expect.hasAssertions(); + let runMode = new RunMode({ type: 'on-save', deferred: true }); + let jestExt = newJestExt({ settings: { runMode } }); + + await jestExt.startSession(); + jestExt.onDidSaveTextDocument(doc); + + expect(mockProcessSession.scheduleProcess).not.toHaveBeenCalled(); + + // while in non-deferred mode, the run will be scheduled + runMode = new RunMode({ type: 'on-save', deferred: false }); + jestExt = newJestExt({ settings: { runMode } }); + + await jestExt.startSession(); + jestExt.onDidSaveTextDocument(doc); + expect(mockProcessSession.scheduleProcess).toHaveBeenCalledWith( + expect.objectContaining({ type: 'by-file' }) + ); + }); + describe('will auto exit defer mode by any on-demand run', () => { + it('when runAllTest is called', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'watch', deferred: true }); + const jestExt = newJestExt({ settings: { runMode } }); + await jestExt.startSession(); + + expect(runMode.config.deferred).toBe(true); + expect(mockOutputTerminal.enable).not.toHaveBeenCalled(); + expect(mockProcessSession.scheduleProcess).not.toHaveBeenCalled(); + + await jestExt.runAllTests(); + + expect(runMode.config.deferred).toBe(false); + expect(mockOutputTerminal.enable).toHaveBeenCalled(); + expect(mockProcessSession.scheduleProcess).toHaveBeenCalledWith( + expect.objectContaining({ type: 'all-tests' }) + ); + }); + it('when executing any itemCommand', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'on-demand', deferred: true }); + const jestExt = newJestExt({ settings: { runMode } }); + await jestExt.startSession(); + + expect(runMode.config.deferred).toBe(true); + expect(mockOutputTerminal.enable).not.toHaveBeenCalled(); + expect(mockTestProvider.runItemCommand).not.toHaveBeenCalled(); + + const testItem: any = {}; + const itemCommand: any = {}; + await jestExt.runItemCommand(testItem, itemCommand); + + expect(runMode.config.deferred).toBe(false); + expect(mockOutputTerminal.enable).toHaveBeenCalled(); + expect(mockTestProvider.runItemCommand).toHaveBeenCalled(); + }); + describe('when triggered explicitly (by UI)', () => { + it.each` + trigger | runTestError + ${undefined} | ${false} + ${{ request: {}, token: {} }} | ${true} + ${{ request: {}, token: {} }} | ${false} + `( + 'with trigger=$trigger, when runTest throws error=${runTestError}', + async ({ trigger, runTestError }) => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'watch', deferred: true }); + const jestExt = newJestExt({ settings: { runMode } }); + + jestExt.triggerUpdateSettings = jest.fn(); + + await jestExt.startSession(); + expect(runMode.config.deferred).toBe(true); + + if (runTestError) { + mockTestProvider.runTests.mockImplementation(() => { + throw new Error('force a test error'); + }); + } + await jestExt.exitDeferMode(trigger); + + expect(runMode.config.deferred).toBe(false); + expect(jestExt.triggerUpdateSettings).toHaveBeenCalled(); + if (trigger) { + expect(mockTestProvider.runTests).toHaveBeenCalled(); + } else { + expect(mockTestProvider.runTests).not.toHaveBeenCalled(); + } + + // if not in deferred mode, no-ops + mockTestProvider.runTests.mockClear(); + jestExt.triggerUpdateSettings = jest.fn(); + await jestExt.exitDeferMode(); + expect(runMode.config.deferred).toBe(false); + expect(jestExt.triggerUpdateSettings).not.toHaveBeenCalled(); + expect(mockTestProvider.runTests).not.toHaveBeenCalled(); + } + ); + }); + }); + }); + it.each` + withError + ${false} + ${true} + `('withError=$withError: can save current runMode to settings', async ({ withError }) => { + expect.hasAssertions(); + + const runMode = new RunMode('watch'); + const jestExt = newJestExt({ settings: { runMode } }); + + if (withError) { + (updateSetting as jest.Mocked).mockImplementation(() => { + throw new Error('forced error'); + }); + } + await jestExt.saveRunMode(); + expect(updateSetting).toHaveBeenCalledWith(jestExt.workspaceFolder, 'runMode', runMode.config); + + if (withError) { + expect(mockOutputTerminal.write).toHaveBeenCalledWith(expect.anything(), 'error'); + } + }); }); diff --git a/tests/JestExt/helper.test.ts b/tests/JestExt/helper.test.ts index b6a330e74..b252bfc61 100644 --- a/tests/JestExt/helper.test.ts +++ b/tests/JestExt/helper.test.ts @@ -1,5 +1,5 @@ jest.unmock('../../src/JestExt/helper'); -jest.unmock('../../src/JestExt/auto-run'); +jest.unmock('../../src/JestExt/run-mode'); jest.unmock('../../src/virtual-workspace-folder'); jest.unmock('../test-helper'); @@ -16,6 +16,7 @@ import { outputFileSuffix, prefixWorkspace, } from '../../src/JestExt/helper'; +import { RunMode } from '../../src/JestExt/run-mode'; import { ProjectWorkspace } from 'jest-editor-support'; import { workspaceLogging } from '../../src/logging'; import { makeWorkspaceFolder, mockProjectWorkspace } from '../test-helper'; @@ -33,7 +34,7 @@ describe('createJestExtContext', () => { console.warn = jest.fn(); }); const baseSettings = { - autoRun: { watch: true }, + runMode: new RunMode('watch'), shell: { toSetting: jest.fn() }, jestCommandLine: 'jest', }; @@ -140,6 +141,7 @@ describe('isWatchRequest', () => { describe('getExtensionResourceSettings()', () => { let userSettings: any; + let mockShell: any; beforeEach(() => { userSettings = {}; (toAbsoluteRootPath as jest.Mocked).mockImplementation( @@ -157,24 +159,22 @@ describe('getExtensionResourceSettings()', () => { } return jest.fn().mockImplementation((key) => userSettings[key] ?? defaults[`jest.${key}`]); }); + mockShell = jest.fn(); + (RunShell as jest.Mocked).mockImplementation(() => mockShell); }); it('should return the extension resource configuration', async () => { - const mockShell = jest.fn(); - (RunShell as jest.Mocked).mockImplementation(() => mockShell); const folder = makeWorkspaceFolder('workspaceFolder1'); expect(getExtensionResourceSettings(folder)).toEqual({ coverageFormatter: 'DefaultFormatter', jestCommandLine: undefined, rootPath: 'workspaceFolder1', - showCoverageOnLoad: false, debugMode: false, coverageColors: null, autoClearTerminal: false, - autoRun: expect.objectContaining({ config: { watch: true } }), + runMode: expect.objectContaining({ config: { type: 'watch', revealOutput: 'on-run' } }), testExplorer: {}, monitorLongRun: 60000, shell: mockShell, - autoRevealOutput: 'on-run', parserPluginOptions: null, enable: true, nodeEnv: undefined, @@ -183,51 +183,74 @@ describe('getExtensionResourceSettings()', () => { expect(createJestSettingGetter).toHaveBeenCalledWith(folder); }); - describe('can read user settings', () => { - let mockShell; - beforeEach(() => { - mockShell = jest.fn(); - (RunShell as jest.Mocked).mockImplementation(() => mockShell); - }); - it('with nodeEnv and shell path', () => { + it('with nodeEnv and shell path', () => { + userSettings = { + nodeEnv: { whatever: '1' }, + shell: mockShell, + }; + const folder = makeWorkspaceFolder('workspaceFolder1'); + const settings = getExtensionResourceSettings(folder); + expect(settings).toEqual( + expect.objectContaining({ + ...userSettings, + }) + ); + }); + describe('testExplorer', () => { + it.each` + testExplorer | showWarning | converted + ${{ enabled: true }} | ${false} | ${{}} + ${{ enabled: false }} | ${true} | ${{}} + ${{ enabled: true, showClassicStatus: true }} | ${true} | ${{}} + ${{ enabled: true, showClassicStatus: true, showInlineError: true }} | ${true} | ${{}} + ${{ showInlineError: true }} | ${false} | ${{ showInlineError: true }} + ${{}} | ${false} | ${{}} + ${null} | ${false} | ${{}} + `( + 'testExplorer: $testExplorer => show legacy warning? $showWarning', + ({ testExplorer, showWarning, converted }) => { + userSettings = { testExplorer }; + const folder = makeWorkspaceFolder('workspaceFolder1'); + const settings = getExtensionResourceSettings(folder); + expect(settings).toEqual( + expect.objectContaining({ + testExplorer: converted, + }) + ); + if (showWarning) { + expect(vscode.window.showWarningMessage).toHaveBeenCalled(); + } + } + ); + }); + describe('runMode', () => { + it('pass along legacy settings', () => { userSettings = { - nodeEnv: { whatever: '1' }, - shell: mockShell, + showCoverageOnLoad: true, + autoRevealOutput: 'off', + autoRun: 'off', }; const folder = makeWorkspaceFolder('workspaceFolder1'); const settings = getExtensionResourceSettings(folder); - expect(settings).toEqual( - expect.objectContaining({ - ...userSettings, - }) - ); + expect(settings.runMode.config).toEqual({ + type: 'on-demand', + revealOutput: 'on-demand', + coverage: true, + }); }); - describe('testExplorer', () => { - it.each` - testExplorer | showWarning | converted - ${{ enabled: true }} | ${false} | ${{}} - ${{ enabled: false }} | ${true} | ${{}} - ${{ enabled: true, showClassicStatus: true }} | ${true} | ${{}} - ${{ enabled: true, showClassicStatus: true, showInlineError: true }} | ${true} | ${{}} - ${{ showInlineError: true }} | ${false} | ${{ showInlineError: true }} - ${{}} | ${false} | ${{}} - ${null} | ${false} | ${{}} - `( - 'testExplorer: $testExplorer => show legacy warning? $showWarning', - ({ testExplorer, showWarning, converted }) => { - userSettings = { testExplorer }; - const folder = makeWorkspaceFolder('workspaceFolder1'); - const settings = getExtensionResourceSettings(folder); - expect(settings).toEqual( - expect.objectContaining({ - testExplorer: converted, - }) - ); - if (showWarning) { - expect(vscode.window.showWarningMessage).toHaveBeenCalled(); - } - } - ); + it('if there is runMode, it will ignore the legacy settings', () => { + userSettings = { + showCoverageOnLoad: true, + autoRevealOutput: 'off', + autoRun: 'off', + runMode: 'on-save', + }; + const folder = makeWorkspaceFolder('workspaceFolder1'); + const settings = getExtensionResourceSettings(folder); + expect(settings.runMode.config).toEqual({ + type: 'on-save', + revealOutput: 'on-run', + }); }); }); }); diff --git a/tests/JestExt/outpt-terminal.test.ts b/tests/JestExt/outpt-terminal.test.ts index 41942f301..cc37629d5 100644 --- a/tests/JestExt/outpt-terminal.test.ts +++ b/tests/JestExt/outpt-terminal.test.ts @@ -28,14 +28,14 @@ describe('JestOutputTerminal', () => { mockEmitter = { fire: jest.fn(), event: jest.fn(), dispose: jest.fn() }; (vscode.EventEmitter as jest.Mocked).mockImplementation(() => mockEmitter); }); - it('will not create terminal until it is revealed', () => { + it('will not create terminal until it is enabled', () => { const output = new JestOutputTerminal('workspace'); output.write('abc'); expect(vscode.window.createTerminal).not.toHaveBeenCalled(); - output.reveal(); + output.enable(); expect(vscode.window.createTerminal).toHaveBeenCalled(); }); - it('can be create with revealed by default', () => { + it('can be create with enabled by default', () => { const output = new JestOutputTerminal('workspace', true); output.write('abc'); expect(vscode.window.createTerminal).toHaveBeenCalled(); @@ -48,7 +48,7 @@ describe('JestOutputTerminal', () => { expect(vscode.window.createTerminal).not.toHaveBeenCalled(); expect(a.dispose).not.toHaveBeenCalled(); - t.reveal(); + t.enable(); t.write('something'); expect(vscode.window.createTerminal).toHaveBeenCalled(); expect(a.dispose).toHaveBeenCalled(); @@ -57,7 +57,7 @@ describe('JestOutputTerminal', () => { it('can buffer output until terminal opened', () => { const output = new JestOutputTerminal('a'); output.write('text 1'); - output.reveal(); + output.enable(); expect(mockEmitter.fire).not.toHaveBeenCalled(); // after open, the buffered text should be sent again @@ -70,7 +70,7 @@ describe('JestOutputTerminal', () => { }); it('if user close the terminal, it will be reopened on the next write', () => { const output = new JestOutputTerminal('a'); - output.reveal(); + output.enable(); output.write('1'); expect(vscode.window.createTerminal).toHaveBeenCalledTimes(1); const { pty } = (vscode.window.createTerminal as jest.Mocked).mock.calls[0][0]; @@ -80,7 +80,7 @@ describe('JestOutputTerminal', () => { // user writes again output.write('1'); - output.reveal(); + output.enable(); // terminal should be opened again with the same pty expect(vscode.window.createTerminal).toHaveBeenCalledTimes(2); const { pty: pty2 } = (vscode.window.createTerminal as jest.Mocked).mock.calls[1][0]; @@ -111,7 +111,7 @@ describe('JestOutputTerminal', () => { }); it('will properly dispose terminal and emitter', () => { const output = new JestOutputTerminal('a'); - output.reveal(); + output.enable(); output.write('1'); output.dispose(); expect(mockTerminal.dispose).toHaveBeenCalled(); @@ -119,7 +119,7 @@ describe('JestOutputTerminal', () => { }); it('can close the terminal', () => { const output = new JestOutputTerminal('a'); - output.reveal(); + output.enable(); output.write('1'); output.close(); expect(mockTerminal.dispose).toHaveBeenCalled(); diff --git a/tests/JestExt/process-listeners.test.ts b/tests/JestExt/process-listeners.test.ts index 766d2efb2..c47cb674f 100644 --- a/tests/JestExt/process-listeners.test.ts +++ b/tests/JestExt/process-listeners.test.ts @@ -56,7 +56,6 @@ describe('jest process listeners', () => { describe('listener base class: AbstractProcessListener', () => { it.each` event | log - ${'processStarting'} | ${false} ${'processClose'} | ${false} ${'processExit'} | ${false} ${'executableJSON'} | ${false} @@ -310,11 +309,6 @@ describe('jest process listeners', () => { expect.hasAssertions(); const listener = new RunTestListener(mockSession); // stdout - listener.onEvent(mockProcess, 'processStarting'); - expect(mockSession.context.onRunEvent.fire).toHaveBeenCalledTimes(1); - expect(mockSession.context.onRunEvent.fire).toHaveBeenCalledWith( - expect.objectContaining({ type: 'process-start' }) - ); mockSession.context.onRunEvent.fire.mockClear(); listener.onEvent(mockProcess, 'processExit'); @@ -508,7 +502,7 @@ describe('jest process listeners', () => { mockSession.context.workspace = { name: 'workspace-xyz' }; mockProcess.request = { type: 'watch-tests' }; }); - it('will fire exit with error', () => { + it('will fire exit with error for watch run', () => { expect.hasAssertions(); const listener = new RunTestListener(mockSession); @@ -521,6 +515,20 @@ describe('jest process listeners', () => { }) ); }); + it('will always file error if error code > 1, regardless of request type', () => { + expect.hasAssertions(); + + mockProcess.request = { type: 'all-tests' }; + const listener = new RunTestListener(mockSession); + + listener.onEvent(mockProcess, 'processClose', 127); + expect(mockSession.context.onRunEvent.fire).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'exit', + error: expect.anything(), + }) + ); + }); }); }); describe('can retry with login-shell if process.env is not correct', () => { diff --git a/tests/JestExt/process-session.test.ts b/tests/JestExt/process-session.test.ts index 6e0bb44bc..b7995b156 100644 --- a/tests/JestExt/process-session.test.ts +++ b/tests/JestExt/process-session.test.ts @@ -1,12 +1,12 @@ jest.unmock('../../src/JestExt/process-session'); jest.unmock('../../src/JestExt/helper'); -jest.unmock('../../src/JestExt/auto-run'); +jest.unmock('../../src/JestExt/run-mode'); jest.unmock('../test-helper'); import { createProcessSession } from '../../src/JestExt/process-session'; import * as listeners from '../../src/JestExt/process-listeners'; import { JestProcessManager } from '../../src/JestProcessManagement'; -import { AutoRun } from '../../src/JestExt/auto-run'; +import { RunMode } from '../../src/JestExt/run-mode'; import { mockJestProcessContext } from '../test-helper'; const mockProcessManager = JestProcessManager as jest.Mocked; @@ -111,27 +111,28 @@ describe('ProcessSession', () => { it('can pass custom request', () => { const sm = createProcessSession(context); expect(mockProcessManager).toHaveBeenCalledTimes(1); - const extraInfo = 'whatever'; - sm.scheduleProcess({ type: 'all-tests', extraInfo }); + const extraInfo: any = 'whatever'; + sm.scheduleProcess({ type: 'all-tests' }, extraInfo); expect(processManagerMock.scheduleJestProcess).toHaveBeenCalled(); expect(processManagerMock.scheduleJestProcess).toHaveBeenCalledWith( - expect.objectContaining({ extraInfo }) + expect.anything(), + extraInfo ); }); }); describe('start', () => { it.each` - autoRun | expectedRequests - ${'off'} | ${[]} - ${{ watch: true, onStartup: ['all-tests'] }} | ${['all-tests', 'watch-tests']} - ${{ watch: true }} | ${['watch-tests']} - ${{ watch: false, onStartup: ['all-tests'] }} | ${['all-tests']} + runMode | expectedRequests + ${new RunMode('on-demand')} | ${[]} + ${new RunMode({ type: 'watch', runAllTestsOnStartup: true })} | ${['all-tests', 'watch-tests']} + ${new RunMode('watch')} | ${['watch-tests']} + ${new RunMode({ type: 'on-demand', runAllTestsOnStartup: true })} | ${['all-tests']} `( - 'will execute the onStartup processes with autoRun=$autoRun', - async ({ autoRun, expectedRequests }) => { + 'will execute the onStartup processes with runMode=$runMode', + async ({ runMode, expectedRequests }) => { expect.hasAssertions(); - context.settings = { autoRun: new AutoRun(autoRun) }; + context.settings = { runMode }; processManagerMock.numberOfProcesses.mockReturnValue(0); const session = createProcessSession(context); await session.start(); @@ -144,7 +145,7 @@ describe('ProcessSession', () => { ); it('will clear all process before starting new ones', async () => { expect.hasAssertions(); - context.settings = { autoRun: new AutoRun({ watch: true }) }; + context.settings = { runMode: new RunMode() }; processManagerMock.numberOfProcesses.mockReturnValue(1); const session = createProcessSession(context); await session.start(); diff --git a/tests/JestExt/run-mode.test.ts b/tests/JestExt/run-mode.test.ts new file mode 100644 index 000000000..9da623009 --- /dev/null +++ b/tests/JestExt/run-mode.test.ts @@ -0,0 +1,608 @@ +jest.unmock('../../src/JestExt/run-mode'); + +import { + RunMode, + RunModeEditor, + RunModeIcons, + runModeDescription, + typeIcon, +} from '../../src/JestExt/run-mode'; +import * as vscode from 'vscode'; +import { updateSetting } from '../../src/Settings'; + +describe('RunMode', () => { + const defaultRunModeConfig = { type: 'watch', revealOutput: 'on-run' }; + describe('constructor', () => { + it.each` + seq | setting | legacySettings | expected + ${1} | ${'watch'} | ${undefined} | ${{ type: 'watch', revealOutput: 'on-run' }} + ${2} | ${'on-save'} | ${undefined} | ${{ type: 'on-save', revealOutput: 'on-run' }} + ${3} | ${'on-demand'} | ${undefined} | ${{ type: 'on-demand', revealOutput: 'on-run' }} + ${4} | ${'deferred'} | ${undefined} | ${{ type: 'on-demand', revealOutput: 'on-run', deferred: true }} + ${5} | ${'typo'} | ${undefined} | ${'error'} + ${6} | ${'watch'} | ${{ autoRun: 'off' }} | ${{ type: 'watch', revealOutput: 'on-run' }} + `( + 'case $seq: creating a RunMode from predefined type: $setting', + ({ setting, legacySettings, expected }) => { + const runMode = new RunMode(setting, legacySettings); + if (expected === 'error') { + expect(runMode.config).toEqual(defaultRunModeConfig); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + } else { + expect(runMode.config).toEqual(expected); + } + } + ); + it.each` + seq | setting | legacySettings | expected + ${1} | ${{ type: 'watch', revealOutput: 'on-run' }} | ${undefined} | ${{ type: 'watch', revealOutput: 'on-run' }} + ${2} | ${{ type: 'watch' }} | ${undefined} | ${{ type: 'watch' }} + ${3} | ${{ type: 'on-save', testFileOnly: true }} | ${undefined} | ${{ type: 'on-save', testFileOnly: true }} + ${4} | ${{ type: 'manual' }} | ${undefined} | ${'error'} + ${5} | ${{ type: 'watch', revealOutput: 'on-run' }} | ${{ autoRun: 'off' }} | ${{ type: 'watch', revealOutput: 'on-run' }} + `( + 'case $seq: creating a RunMode from existing config without change: $setting', + ({ setting, legacySettings, expected }) => { + const runMode = new RunMode(setting, legacySettings); + if (expected === 'error') { + expect(runMode.config).toEqual(defaultRunModeConfig); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + } else { + expect(runMode.config).toEqual(expected); + } + } + ); + describe(`migrating from existing settings`, () => { + it.each` + seq | legacySettings | expected + ${1} | ${{ autoRun: 'off' }} | ${{ type: 'on-demand', revealOutput: 'on-run' }} + ${2} | ${{ autoRun: 'watch' }} | ${{ type: 'watch', revealOutput: 'on-run' }} + ${3} | ${{ autoRun: 'on-save' }} | ${{ type: 'on-save', revealOutput: 'on-run' }} + ${4} | ${{ autoRun: 'legacy' }} | ${{ type: 'watch', revealOutput: 'on-run', runAllTestsOnStartup: true }} + ${5} | ${{ autoRun: { watch: true, onStartup: ['all-tests'] } }} | ${{ type: 'watch', revealOutput: 'on-run', runAllTestsOnStartup: true }} + ${6} | ${{ autoRun: { watch: false, onSave: 'test-src-file' } }} | ${{ type: 'on-save', revealOutput: 'on-run' }} + ${7} | ${{ autoRun: { watch: false, onSave: 'test-file' } }} | ${{ type: 'on-save', revealOutput: 'on-run', testFileOnly: true }} + ${8} | ${{ autoRun: 'typo' }} | ${'error'} + `('case $seq: can create autoRun from autoRun settings', ({ legacySettings, expected }) => { + const runMode = new RunMode(undefined, legacySettings); + if (expected === 'error') { + expect(runMode.config).toEqual(defaultRunModeConfig); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + } else { + expect(runMode.config).toEqual(expected); + } + }); + it.each` + seq | setting | legacySettings | expected + ${1} | ${undefined} | ${{ showCoverageOnLoad: true }} | ${{ type: 'watch', revealOutput: 'on-run', coverage: true }} + ${2} | ${'watch'} | ${{ showCoverageOnLoad: true }} | ${{ type: 'watch', revealOutput: 'on-run' }} + ${3} | ${undefined} | ${{ autoRevealOutput: 'off' }} | ${{ type: 'watch', revealOutput: 'on-demand' }} + ${4} | ${undefined} | ${{ autoRevealOutput: 'on-run' }} | ${{ type: 'watch', revealOutput: 'on-run' }} + ${5} | ${undefined} | ${{ autoRevealOutput: 'on-exec-error' }} | ${{ type: 'watch', revealOutput: 'on-exec-error' }} + ${6} | ${undefined} | ${{ autoRevealOutput: 'something' }} | ${'error'} + ${7} | ${undefined} | ${{ showCoverageOnLoad: true }} | ${{ type: 'watch', revealOutput: 'on-run', coverage: true }} + ${8} | ${undefined} | ${{ showCoverageOnLoad: false }} | ${{ type: 'watch', revealOutput: 'on-run' }} + ${9} | ${undefined} | ${{ autoRun: 'off', autoRevealOutput: 'off', showCoverageOnLoad: true }} | ${{ type: 'on-demand', revealOutput: 'on-demand', coverage: true }} + `( + 'case $seq: migrating other legacy settings to RunMode', + ({ setting, legacySettings, expected }) => { + const runMode = new RunMode(setting, legacySettings); + if (expected === 'error') { + expect(runMode.config).toEqual(defaultRunModeConfig); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + } else { + expect(runMode.config).toEqual(expected); + } + } + ); + }); + }); + it('can exit defer mode', () => { + const runMode = new RunMode({ type: 'watch', deferred: true }); + expect(runMode.config.deferred).toBe(true); + runMode.exitDeferMode(); + expect(runMode.config.deferred).toBe(false); + expect(runMode.isModified).toBe(true); + }); + it('can toggle coverage', () => { + const runMode = new RunMode({ type: 'watch' }); + expect(runMode.config.coverage).toBe(undefined); + runMode.toggleCoverage(); + expect(runMode.config.coverage).toBe(true); + expect(runMode.isModified).toBe(true); + }); + + describe('can popup a picker to change run mode', () => { + let mockQuickPick: any; + const createMockQuickPick = () => { + let hideFunc: any; + let exitFunction: any; + let itemButtonFunction: any; + const qp: any = { + onDidTriggerButton: jest.fn().mockImplementation((cb) => { + exitFunction = cb; + }), + onDidTriggerItemButton: jest.fn().mockImplementation((cb) => { + itemButtonFunction = cb; + }), + onDidChangeActive: jest.fn(), + onDidChangeSelection: jest.fn(), + onDidHide: jest.fn().mockImplementation((cb) => { + hideFunc = cb; + }), + dispose: jest.fn(), + hide: jest.fn().mockImplementation(() => { + hideFunc(); + }), + show: jest.fn(), + + // convenience functions for mocking + cancel: () => { + const [backButton] = qp.buttons; + exitFunction(backButton); + }, + accept: (item: any) => { + const [, acceptButton] = qp.buttons; + qp.activeItems = [item]; + exitFunction(acceptButton); + }, + triggerItemButton: (item: any, button: any) => { + return itemButtonFunction({ item, button }); + }, + }; + return qp; + }; + beforeEach(() => { + mockQuickPick = createMockQuickPick(); + vscode.window.createQuickPick = jest.fn().mockReturnValue(mockQuickPick); + (vscode.QuickInputButtons as jest.Mocked) = { Back: {} }; + vscode.Uri.parse = jest.fn().mockReturnValue({ fsPath: 'test' }); + vscode.window.visibleTextEditors = []; + }); + + it('pop up a quick pick and returns a new runMode from selection', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'watch', runAllTestsOnStartup: true }); + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + expect(mockQuickPick.show).toHaveBeenCalled(); + + expect(mockQuickPick.items.length).toBe(3); + expect(mockQuickPick.items.map((item) => item.label)).toEqual([ + expect.stringContaining('watch'), + expect.stringContaining('on-save'), + expect.stringContaining('on-demand'), + ]); + + // the item matches the the current runMode should be active and its content should match the current runMode + const current = mockQuickPick.items.find((item) => item.label.includes(runMode.config.type)); + expect(current.mode).toEqual(runMode.config); + expect(current.description).toEqual(expect.stringContaining('current')); + expect(mockQuickPick.activeItems).toEqual([current]); + + // select and accept the 'on-demand' mode + const next = mockQuickPick.items.find((item) => item.label.includes('on-demand')); + mockQuickPick.accept(next); + + expect(mockQuickPick.hide).toHaveBeenCalled(); + + const newRunMode = await p; + + expect(newRunMode.config).toEqual(next.mode); + expect(mockQuickPick.dispose).toHaveBeenCalled(); + expect(runMode).not.toBe(newRunMode); + expect(newRunMode.isModified).toBe(true); + }); + it('can quick toggle coverage and deferred', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'watch', coverage: true }); + + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + expect(mockQuickPick.show).toHaveBeenCalled(); + + const current = mockQuickPick.items.find((item) => item.label.includes(runMode.config.type)); + expect(current.mode.coverage).toBe(true); + expect(current.mode.deferred).toBeFalsy(); + + const [coverageButton, deferredButton] = current.buttons; + + mockQuickPick.triggerItemButton(current, coverageButton); + expect(current.mode.coverage).toBe(false); + mockQuickPick.triggerItemButton(current, deferredButton); + expect(current.mode.deferred).toBe(true); + + mockQuickPick.accept(current); + + const newRunMode = await p; + + expect(newRunMode.config).toEqual(current.mode); + expect(mockQuickPick.dispose).toHaveBeenCalled(); + expect(runMode).not.toBe(newRunMode); + expect(newRunMode.isModified).toBe(true); + }); + it('can cancel quickPick without change runMode', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'watch', coverage: true }); + + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + expect(mockQuickPick.show).toHaveBeenCalled(); + + mockQuickPick.cancel(); + const result = await p; + expect(result).toBeUndefined(); + }); + describe('can open text editor to edit runMode', () => { + let editSpy: any; + let closeSpy: any; + beforeEach(() => { + editSpy = jest.spyOn(RunModeEditor.prototype, 'edit'); + closeSpy = jest.spyOn(RunModeEditor.prototype, 'close'); + }); + it('when content changed successfully', async () => { + expect.hasAssertions(); + const runMode = new RunMode({ type: 'watch', coverage: true }); + const edited = { type: 'watch', coverage: false }; + editSpy.mockResolvedValueOnce(edited); + + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + expect(mockQuickPick.show).toHaveBeenCalled(); + const current = mockQuickPick.items.find((item) => + item.label.includes(runMode.config.type) + ); + const [, , editButton] = current.buttons; + await mockQuickPick.triggerItemButton(current, editButton); + expect(editSpy).toHaveBeenCalled(); + + mockQuickPick.accept(current); + const newRunMode = await p; + + expect(newRunMode.config).toEqual(edited); + expect(mockQuickPick.dispose).toHaveBeenCalled(); + expect(runMode).not.toBe(newRunMode); + expect(newRunMode.isModified).toBe(true); + }); + it('when content change is a pre-defined string', async () => { + expect.hasAssertions(); + const runMode = new RunMode({ type: 'watch', coverage: true }); + const edited = 'deferred'; + editSpy.mockResolvedValueOnce(edited); + + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + expect(mockQuickPick.show).toHaveBeenCalled(); + const current = mockQuickPick.items.find((item) => + item.label.includes(runMode.config.type) + ); + const [, , editButton] = current.buttons; + await mockQuickPick.triggerItemButton(current, editButton); + expect(editSpy).toHaveBeenCalled(); + + // the "current" item should not have changed + expect(current.mode.type).toBe(runMode.config.type); + + // the active item should be the "deferred" item + const next = mockQuickPick.items.find((item) => item.label.includes('on-demand')); + expect(next.mode.deferred).toEqual(true); + expect(mockQuickPick.activeItems).toEqual([next]); + + mockQuickPick.accept(next); + const newRunMode = await p; + + expect(newRunMode.config).toEqual(next.mode); + expect(mockQuickPick.dispose).toHaveBeenCalled(); + expect(runMode).not.toBe(newRunMode); + expect(newRunMode.isModified).toBe(true); + }); + it('when user aborts the edit', async () => { + expect.hasAssertions(); + const runMode = new RunMode({ type: 'watch', coverage: true }); + editSpy.mockResolvedValueOnce(undefined); + + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + expect(mockQuickPick.show).toHaveBeenCalled(); + const current = mockQuickPick.items.find((item) => + item.label.includes(runMode.config.type) + ); + const [, , editButton] = current.buttons; + await mockQuickPick.triggerItemButton(current, editButton); + expect(editSpy).toHaveBeenCalled(); + + mockQuickPick.accept(current); + const newRunMode = await p; + + expect(newRunMode.config).toEqual(runMode.config); + expect(mockQuickPick.dispose).toHaveBeenCalled(); + expect(runMode).not.toBe(newRunMode); + expect(newRunMode.isModified).toBe(true); + }); + it('when user abort the quick pick, the runMode editor should be closed as well', async () => { + expect.hasAssertions(); + const runMode = new RunMode({ type: 'watch', coverage: true }); + editSpy.mockResolvedValueOnce(undefined); + + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + expect(mockQuickPick.show).toHaveBeenCalled(); + const current = mockQuickPick.items.find((item) => + item.label.includes(runMode.config.type) + ); + const [, , editButton] = current.buttons; + mockQuickPick.triggerItemButton(current, editButton); + expect(editSpy).toHaveBeenCalled(); + + mockQuickPick.cancel(); + const newRunMode = await p; + + expect(newRunMode).toBeUndefined(); + expect(closeSpy).toHaveBeenCalled(); + expect(mockQuickPick.dispose).toHaveBeenCalled(); + expect(runMode.isModified).toBe(false); + }); + }); + it('can restore the original runMode', async () => { + expect.hasAssertions(); + const original = new RunMode({ type: 'watch' }); + + const runMode = new RunMode(original.config); + runMode.toggleCoverage(); + + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + expect(mockQuickPick.show).toHaveBeenCalled(); + + expect(mockQuickPick.items.length).toBe(5); + const restoreLabel = 'Restore original runMode'; + expect(mockQuickPick.items.map((item) => item.label)).toEqual([ + expect.stringContaining('watch'), + expect.stringContaining('on-save'), + expect.stringContaining('on-demand'), + expect.anything(), // separator + expect.stringContaining(restoreLabel), + ]); + + // the item matches the the current runMode should be active and its content should match the current runMode + const restoreItem = mockQuickPick.items.find((item) => item.label.includes(restoreLabel)); + expect(restoreItem.mode).toEqual(original.config); + + mockQuickPick.accept(restoreItem); + + expect(mockQuickPick.hide).toHaveBeenCalled(); + + const newRunMode = await p; + + expect(newRunMode.config).toEqual(original.config); + expect(mockQuickPick.dispose).toHaveBeenCalled(); + expect(runMode).not.toBe(newRunMode); + expect(original).not.toBe(newRunMode); + expect(newRunMode.isModified).toBe(false); + }); + it('can disable the selection model and use active items exclusively', async () => { + expect.hasAssertions(); + const runMode = new RunMode('watch'); + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + + expect(mockQuickPick.show).toHaveBeenCalled(); + expect(mockQuickPick.selectedItems).toBeUndefined(); + + // simulate clicking on the "on-save" item: change the selection + const next = mockQuickPick.items.find((item) => item.label.includes('on-save')); + const onSelect = mockQuickPick.onDidChangeSelection.mock.calls[0][0]; + onSelect([next]); + + // the selected items should still be empty + expect(mockQuickPick.selectedItems).toEqual([]); + + mockQuickPick.cancel(); + await p; + }); + it('can workaround issue microsoft/vscode#75005', async () => { + expect.hasAssertions(); + const runMode = new RunMode('on-demand'); + const p = runMode.quickSwitch({ asAbsolutePath: jest.fn() } as any); + + const watchItem = mockQuickPick.items.find((item) => item.label.includes('watch')); + const current = mockQuickPick.items.find((item) => item.label.includes(runMode.config.type)); + expect(mockQuickPick.activeItems).toEqual([current]); + + // toggle item button caused the quick pick to update its items, which then trigger the activeItems to be reset to the first item. + // Even though we try to set the activeItems to the current item right away, it seems to be override by the quick pick reset action. + // so the workaround is to force the activeItems to be the "actual" one for the next 2 onDidChangeActive events + // this seems to be very hacky but it has been working since 2019. So let's hope it will continue to work. :) + + const [coverageButton] = current.buttons; + await mockQuickPick.triggerItemButton(current, coverageButton); + + const onDidChangeActive = mockQuickPick.onDidChangeActive.mock.calls[0][0]; + + // 1st time: the activeItems should be override with the "actual" one + mockQuickPick.activeItems = [watchItem]; + onDidChangeActive([watchItem]); + expect(mockQuickPick.activeItems).toEqual([current]); + + // 2nd time: the activeItems should be override with the "actual" one + mockQuickPick.activeItems = [watchItem]; + onDidChangeActive([watchItem]); + expect(mockQuickPick.activeItems).toEqual([current]); + + // 3rd time and onwards should be back to normal operation + mockQuickPick.activeItems = [watchItem]; + onDidChangeActive([watchItem]); + expect(mockQuickPick.activeItems).toEqual([watchItem]); + + mockQuickPick.cancel(); + await p; + }); + }); + it('can save runMode to settings.json', async () => { + expect.hasAssertions(); + const runMode = new RunMode({ type: 'watch' }); + (updateSetting as jest.Mocked) = jest.fn().mockResolvedValue(undefined); + const ws: any = {}; + await runMode.save(ws); + expect(updateSetting).toHaveBeenCalledWith(ws, 'runMode', runMode.config); + }); +}); + +describe('RunModeEditor', () => { + let doc, editor; + let onDidSaveTextDocument, onDidCloseTextDocument; + const disposable = { dispose: jest.fn() }; + const schemaUri: any = { fsPath: 'schema' }; + const runModeFileUri: any = { toString: () => 'runMode.json' }; + + beforeEach(() => { + doc = { + uri: runModeFileUri, + lineAt: jest.fn().mockReturnValue({ range: { start: 0, end: 100 } }), + save: jest.fn(), + getText: jest.fn().mockReturnValue(''), + }; + editor = { document: doc, edit: jest.fn() }; + vscode.window.showTextDocument = jest.fn().mockResolvedValue(editor); + (vscode.workspace as jest.Mocked) = { + openTextDocument: jest.fn().mockResolvedValue(doc), + onDidSaveTextDocument: jest.fn().mockImplementation((cb) => { + onDidSaveTextDocument = cb; + return disposable; + }), + onDidCloseTextDocument: jest.fn().mockImplementation((cb) => { + onDidCloseTextDocument = cb; + return disposable; + }), + }; + vscode.window.visibleTextEditors = [editor]; + vscode.languages.setTextDocumentLanguage = jest.fn().mockResolvedValue(undefined); + vscode.Uri.parse = jest.fn().mockReturnValue(runModeFileUri); + }); + it('can open the editor with the runMode content', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'on-save' }); + + const runModeEditor = new RunModeEditor(); + const p = runModeEditor.edit(runMode.config, schemaUri); + + // Await a tick of the JavaScript event loop so that the promise is properly initialized. + await new Promise((resolve) => setImmediate(resolve)); + + // the editor should be opened with the runMode content + const cb = editor.edit.mock.calls[0][0]; + const mockEditBuffer = { replace: jest.fn() }; + cb(mockEditBuffer); + let content = mockEditBuffer.replace.mock.calls[0][1]; + // remove whitespace + content = content.replace(/\s+/g, ''); + expect(content).toEqual( + expect.stringContaining(`"jest.runMode":${JSON.stringify(runMode.config)}`) + ); + + // the doc should have jsonc languageId + expect(vscode.languages.setTextDocumentLanguage).toHaveBeenCalledWith(doc, 'jsonc'); + + // close without save - abort + await onDidCloseTextDocument(doc); + await expect(p).resolves.toBeUndefined(); + }); + it('can force close the editor', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'on-save' }); + + const runModeEditor = new RunModeEditor(); + const p = runModeEditor.edit(runMode.config, schemaUri); + + // Await a tick of the JavaScript event loop so that the promise is properly initialized. + await new Promise((resolve) => setImmediate(resolve)); + + // force close the editor before the promise is resolved in edit() + await runModeEditor.close(); + await expect(p).resolves.toBeUndefined(); + + // a text editor should be presented + expect(vscode.window.showTextDocument).toHaveBeenCalled(); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'workbench.action.closeActiveEditor' + ); + }); + it('edit with valid changes', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'on-save' }); + + const runModeEditor = new RunModeEditor(); + const p = runModeEditor.edit(runMode.config, schemaUri); + + // Await a tick of the JavaScript event loop so that the promise is properly initialized. + await new Promise((resolve) => setImmediate(resolve)); + + // save the change + const newConfig = { type: 'on-save', coverage: true, testFileOnly: true }; + doc.getText.mockReturnValueOnce(`{"jest.runMode": ${JSON.stringify(newConfig)}}`); + await onDidSaveTextDocument(doc); + + await expect(p).resolves.toEqual(newConfig); + }); + it('edit with invalid changes', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'on-save' }); + + const runModeEditor = new RunModeEditor(); + const p = runModeEditor.edit(runMode.config, schemaUri); + + // Await a tick of the JavaScript event loop so that the promise is properly initialized. + await new Promise((resolve) => setImmediate(resolve)); + + // save the change + const newConfig = { type: 'on-save', coverage: true, testFileOnly: true }; + doc.getText + .mockReturnValueOnce('') // invalid json content + .mockReturnValueOnce(`{"jest.runMode": ${JSON.stringify(newConfig)}}`); + await onDidSaveTextDocument(doc); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + + // save again with the valid json + await onDidSaveTextDocument(doc); + + const edited = await p; + expect(edited).toEqual(newConfig); + }); + it('close editor without save', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'on-save' }); + + const runModeEditor = new RunModeEditor(); + const p = runModeEditor.edit(runMode.config, schemaUri); + + // Await a tick of the JavaScript event loop so that the promise is properly initialized. + await new Promise((resolve) => setImmediate(resolve)); + + // close the doc without save + await onDidCloseTextDocument(doc); + + await expect(p).resolves.toBeUndefined(); + }); +}); + +describe('runModeDescription', () => { + it.each` + seq | config | description + ${1} | ${{ type: 'watch' }} | ${{ type: RunModeIcons['watch'] }} + ${2} | ${{ type: 'watch', coverage: true }} | ${{ type: RunModeIcons['watch'], coverage: RunModeIcons['coverage'] }} + ${3} | ${{ type: 'on-demand', coverage: true, deferred: true }} | ${{ type: RunModeIcons['on-demand'], coverage: RunModeIcons['coverage'], deferred: RunModeIcons['deferred'] }} + ${4} | ${{ type: 'on-save' }} | ${{ type: RunModeIcons['on-save'] }} + ${5} | ${{ type: 'on-save', testFileOnly: true }} | ${{ type: RunModeIcons['on-save-test-file-only'] }} + `('case $seq: returns the correct RunModeDescription', ({ config, description }) => { + expect(runModeDescription(config)).toEqual(description); + }); +}); +describe('typeIcon', () => { + it.each` + seq | config | icon + ${1} | ${{ type: 'watch' }} | ${RunModeIcons['watch']} + ${2} | ${{ type: 'watch', coverage: true }} | ${RunModeIcons['watch']} + ${3} | ${{ type: 'watch', deferred: true }} | ${RunModeIcons['deferred']} + `('case $seq: returns the correct RunModeIcon', ({ config, icon }) => { + expect(typeIcon(config)).toEqual(icon); + }); +}); diff --git a/tests/JestProcessManagement/JestProcess.test.ts b/tests/JestProcessManagement/JestProcess.test.ts index bafc16c0b..d18c83fa5 100644 --- a/tests/JestProcessManagement/JestProcess.test.ts +++ b/tests/JestProcessManagement/JestProcess.test.ts @@ -94,16 +94,6 @@ describe('JestProcess', () => { await expect(p).resolves.not.toThrow(); expect(jp.stopReason).toEqual('process-end'); }); - it('will emit processStart event upon starting', () => { - expect.hasAssertions(); - const request = mockRequest('all-tests'); - const jp = new JestProcess(extContext, request); - jp.start(); - - expect(RunnerClassMock).toHaveBeenCalled(); - const [, event] = mockListener.onEvent.mock.calls[0]; - expect(event).toEqual('processStarting'); - }); it.each` event | willEndProcess ${'processClose'} | ${true} @@ -124,7 +114,7 @@ describe('JestProcess', () => { expect(mockRunner.on).toHaveBeenCalledTimes(RunnerEvents.length); eventEmitter.emit(event); - const [process, _event] = mockListener.onEvent.mock.calls[1]; + const [process, _event] = mockListener.onEvent.mock.calls[0]; expect(process).toBe(jp); expect(_event).toEqual(event); diff --git a/tests/JestProcessManagement/JestProcessManager.test.ts b/tests/JestProcessManagement/JestProcessManager.test.ts index 8af99fc12..099398be4 100644 --- a/tests/JestProcessManagement/JestProcessManager.test.ts +++ b/tests/JestProcessManagement/JestProcessManager.test.ts @@ -93,10 +93,14 @@ describe('JestProcessManager', () => { expect.hasAssertions(); const process = pm.scheduleJestProcess(request); + expect(process.id).toEqual(expect.stringContaining(request.type)); expect(jestProcessMock).toHaveBeenCalledTimes(1); expect(mockProcess.start).toHaveBeenCalledTimes(1); + expect(extContext.onRunEvent.fire).toHaveBeenCalledWith( + expect.objectContaining({ type: 'process-start' }) + ); expect(getState(pm, mockProcess)).toEqual({ inQ: true, started: true, qSize: 1 }); }); it('the queue will be cleared when the process exit upon completion', async () => { diff --git a/tests/Settings/index.test.ts b/tests/Settings/index.test.ts index 28771b59b..2c113875f 100644 --- a/tests/Settings/index.test.ts +++ b/tests/Settings/index.test.ts @@ -3,7 +3,11 @@ jest.unmock('../test-helper'); jest.unmock('../../src/virtual-workspace-folder'); import * as vscode from 'vscode'; -import { createJestSettingGetter } from '../../src/Settings/index'; +import { + VirtualFolderSettingKey, + createJestSettingGetter, + updateSetting, +} from '../../src/Settings/index'; import { VirtualWorkspaceFolder } from '../../src/virtual-workspace-folder'; import { makeWorkspaceFolder } from '../test-helper'; @@ -82,3 +86,58 @@ describe('createJestSettingGetter', () => { } ); }); + +describe('updateSetting', () => { + const workspaceFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.parse('file:///path/to/workspace'), + name: 'workspace', + index: 0, + }; + const mockConfig = { + update: jest.fn(), + get: jest.fn(), + }; + beforeEach(() => { + jest.resetAllMocks(); + + vscode.workspace.getConfiguration = jest.fn().mockReturnValue(mockConfig); + }); + + it('updates the setting for a non-virtual workspace folder', async () => { + const key: VirtualFolderSettingKey = 'enable'; + const value = false; + + await updateSetting(workspaceFolder, key, value); + + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith('jest', workspaceFolder.uri); + expect(mockConfig.update).toHaveBeenCalledWith(key, value); + }); + + it('updates the setting for a virtual workspace folder', async () => { + const v1Folder = { name: 'v1', rootPath: '/path/to/v1', enable: true }; + mockConfig.get.mockReturnValueOnce([v1Folder]); + const key: VirtualFolderSettingKey = 'enable'; + const value = false; + + const v1 = new VirtualWorkspaceFolder(workspaceFolder, 'v1'); + await updateSetting(v1, key, value); + + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith('jest', workspaceFolder.uri); + expect(mockConfig.get).toHaveBeenCalledWith('virtualFolders'); + + expect(mockConfig.update).toHaveBeenCalledWith('virtualFolders', [ + { ...v1Folder, enable: false }, + ]); + }); + + it('throws an error if the virtual folder setting is missing', async () => { + const v1Folder = { name: 'v1', rootPath: '/path/to/v1', enable: true }; + mockConfig.get.mockReturnValueOnce([v1Folder]); + + const key: VirtualFolderSettingKey = 'enable'; + const value = false; + + const v2 = new VirtualWorkspaceFolder(workspaceFolder, 'v2'); + await expect(updateSetting(v2, key, value)).rejects.toThrow(); + }); +}); diff --git a/tests/StatusBar.test.ts b/tests/StatusBar.test.ts index 0b6858cc5..f3da1bdad 100644 --- a/tests/StatusBar.test.ts +++ b/tests/StatusBar.test.ts @@ -1,5 +1,6 @@ jest.unmock('../src/StatusBar'); jest.unmock('../src/virtual-workspace-folder'); +jest.unmock('../src/JestExt/run-mode'); jest.unmock('./test-helper'); jest.useFakeTimers(); @@ -11,6 +12,7 @@ import { TestStats } from '../src/types'; import { makeUri, makeWorkspaceFolder } from './test-helper'; import { VirtualWorkspaceFolder } from '../src/virtual-workspace-folder'; import { isInFolder } from '../src/workspace-manager'; +import { RunMode } from '../src/JestExt/run-mode'; const mockSummaryChannel = { append: jest.fn(), @@ -189,17 +191,17 @@ describe('StatusBar', () => { const emptyStatsString = `$(pass) 0 $(error) 0 $(question) 0`; it.each` - seq | update | active | summary | backgroundColor - ${1} | ${{ state: 'running' }} | ${'$(sync~spin)'} | ${emptyStatsString} | ${undefined} - ${2} | ${{ state: 'done' }} | ${''} | ${emptyStatsString} | ${undefined} - ${3} | ${{ mode: ['auto-run-watch', 'coverage'] }} | ${'$(eye) $(color-mode)'} | ${emptyStatsString} | ${undefined} - ${4} | ${{ stats: alertStats }} | ${''} | ${alertSummary} | ${undefined} - ${5} | ${{ mode: ['auto-run-off'], stats: passStats }} | ${'$(wrench)'} | ${'$(check)'} | ${undefined} - ${6} | ${{ state: 'exec-error' }} | ${'alert'} | ${emptyStatsString} | ${'statusBarItem.errorBackground'} - ${7} | ${{ state: 'initial' }} | ${'...'} | ${emptyStatsString} | ${undefined} - ${8} | ${{ state: 'stopped' }} | ${'stopped'} | ${emptyStatsString} | ${'statusBarItem.errorBackground'} - ${9} | ${{ mode: ['auto-run-on-save-test'], stats: alertStats }} | ${'$(save)'} | ${alertSummary} | ${undefined} - `('update: $update', ({ update, active, summary, backgroundColor }) => { + seq | update | active | summary | backgroundColor + ${1} | ${{ state: 'running' }} | ${'$(sync~spin)'} | ${emptyStatsString} | ${undefined} + ${2} | ${{ state: 'done' }} | ${''} | ${emptyStatsString} | ${undefined} + ${3} | ${{ mode: new RunMode({ type: 'watch', coverage: true }) }} | ${'$(eye) $(color-mode)'} | ${emptyStatsString} | ${undefined} + ${4} | ${{ stats: alertStats }} | ${''} | ${alertSummary} | ${undefined} + ${5} | ${{ mode: new RunMode('on-demand'), stats: passStats }} | ${'$(run)'} | ${'$(check)'} | ${undefined} + ${6} | ${{ state: 'exec-error' }} | ${'alert'} | ${emptyStatsString} | ${'statusBarItem.errorBackground'} + ${7} | ${{ state: 'initial' }} | ${'...'} | ${emptyStatsString} | ${undefined} + ${8} | ${{ state: 'stopped' }} | ${'stopped'} | ${emptyStatsString} | ${'statusBarItem.errorBackground'} + ${9} | ${{ mode: new RunMode({ type: 'on-save', testFileOnly: true }), stats: alertStats }} | ${'$(save)'} | ${alertSummary} | ${undefined} + `('$seq: update: $update', ({ update, active, summary, backgroundColor }) => { statusBar.bind(makeWorkspaceFolder('testSource1')).update(update); expect(renderSpy).toHaveBeenCalledTimes(2); expect(mockActiveSBItems[0].text).toContain(active); @@ -228,8 +230,8 @@ describe('StatusBar', () => { it('shows tooltip by the actual status', () => { statusBar .bind(makeWorkspaceFolder('testSource1')) - .update({ mode: ['auto-run-on-save'], stats: { success: 1, fail: 2, unknown: 3 } }); - expect(mockActiveSBItems[0].tooltip).toContain('auto-run-on-save'); + .update({ mode: new RunMode('on-save'), stats: { success: 1, fail: 2, unknown: 3 } }); + expect(mockActiveSBItems[0].tooltip).toContain('on-save'); expect(mockSummarySBItems[0].tooltip).toContain('success 1, fail 2, unknown 3'); }); }); @@ -340,15 +342,15 @@ describe('StatusBar', () => { setupWorkspace('testSource1', 'testSource2', 'testSource3'); statusBar.bind(makeWorkspaceFolder('testSource1')).update({ state: 'initial', - mode: ['auto-run-off'], + mode: new RunMode('on-demand'), }); statusBar.bind(makeWorkspaceFolder('testSource2')).update({ state: 'running', - mode: ['auto-run-watch', 'coverage'], + mode: new RunMode({ type: 'watch', coverage: true }), }); statusBar.bind(makeWorkspaceFolder('testSource3')).update({ state: 'done', - mode: ['auto-run-on-save-test', 'coverage'], + mode: new RunMode({ type: 'on-save', testFileOnly: true, coverage: true }), }); }); it.each` @@ -367,14 +369,14 @@ describe('StatusBar', () => { mockSummaryChannel.append.mockClear(); statusBar.bind(makeWorkspaceFolder('testSource1')).update({ state: 'running', - mode: ['auto-run-watch', 'coverage'], + mode: new RunMode({ type: 'watch', coverage: true }), stats: makeStats(1, 2, 3), }); const output = mockSummaryChannel.append.mock.calls[0][0]; expect(output).toMatchInlineSnapshot(` - "testSource1: warning | success 1, fail 2, unknown 3; mode: auto-run-watch, coverage; state: running - testSource2: mode: auto-run-watch, coverage; state: running - testSource3: mode: auto-run-on-save-test, coverage; state: idle" + "testSource1: warning | success 1, fail 2, unknown 3; mode: watch, coverage; state: running + testSource2: mode: watch, coverage; state: running + testSource3: mode: on-save-test-file-only, coverage; state: idle" `); }); }); diff --git a/tests/extension-manager.test.ts b/tests/extension-manager.test.ts index 691e98fd0..cb6763bf9 100644 --- a/tests/extension-manager.test.ts +++ b/tests/extension-manager.test.ts @@ -3,7 +3,7 @@ jest.unmock('../src/virtual-workspace-folder'); jest.unmock('../src/appGlobals'); import * as vscode from 'vscode'; -import { addFolderToDisabledWorkspaceFolders, ExtensionManager } from '../src/extension-manager'; +import { ExtensionManager } from '../src/extension-manager'; import { readFileSync } from 'fs'; import { extensionName } from '../src/appGlobals'; import { JestExt } from '../src/JestExt'; @@ -11,6 +11,8 @@ import { DebugConfigurationProvider } from '../src/DebugConfigurationProvider'; import { CoverageCodeLensProvider } from '../src/Coverage'; import { startWizard } from '../src/setup-wizard'; import { VirtualWorkspaceFolder } from '../src/virtual-workspace-folder'; +import { updateSetting } from '../src/Settings'; +import { showQuickFix } from '../src/quick-fix'; const mockEnabledWorkspaceFolders = jest.fn(); jest.mock('../src/workspace-manager', () => ({ @@ -69,10 +71,13 @@ const makeJestExt = (workspace: vscode.WorkspaceFolder): any => { onDidSaveTextDocument: jest.fn(), onWillSaveTextDocument: jest.fn(), triggerUpdateSettings: jest.fn(), - toggleAutoRun: jest.fn(), - toggleCoverageOverlay: jest.fn(), + toggleCoverage: jest.fn(), enableLoginShell: jest.fn(), runItemCommand: jest.fn(), + changeRunMode: jest.fn(), + saveRunMode: jest.fn(), + exitDeferMode: jest.fn(), + setupExtensionForFolder: jest.fn(), workspaceFolder: workspace, name: workspace.name, }; @@ -150,8 +155,8 @@ describe('ExtensionManager', () => { let extensionManager: ExtensionManager; const isInWorkspaceSpy = jest.spyOn(VirtualWorkspaceFolder.prototype, 'isInWorkspaceFolder'); - const registerSpy = jest.spyOn(ExtensionManager.prototype, 'addExtension'); - const unregisterSpy = jest.spyOn(ExtensionManager.prototype, 'deleteExtension'); + const addExtensionSpy = jest.spyOn(ExtensionManager.prototype, 'addExtension'); + const deleteExtensionSpy = jest.spyOn(ExtensionManager.prototype, 'deleteExtension'); let workspaceFolder1; beforeEach(() => { @@ -167,7 +172,7 @@ describe('ExtensionManager', () => { describe('constructor()', () => { it('should register extensions for all workspace folders', () => { new ExtensionManager(context); - expect(registerSpy).toHaveBeenCalledTimes(1); + expect(addExtensionSpy).toHaveBeenCalledTimes(1); }); it('should created the components shared across of all workspaces', () => { new ExtensionManager(context); @@ -183,7 +188,7 @@ describe('ExtensionManager', () => { taskId: 'monorepo', }); new ExtensionManager(context); - expect(registerSpy).not.toHaveBeenCalled(); + expect(addExtensionSpy).not.toHaveBeenCalled(); expect(startWizard).toHaveBeenCalledWith( expect.anything(), @@ -197,7 +202,7 @@ describe('ExtensionManager', () => { taskId: 'monorepo', }); new ExtensionManager(context); - expect(registerSpy).toHaveBeenCalled(); + expect(addExtensionSpy).toHaveBeenCalled(); }); }); describe('with an extensionManager', () => { @@ -206,8 +211,8 @@ describe('ExtensionManager', () => { jestInstances.length = 0; const recordInstances = (ext: any) => jestInstances.push(ext); extensionManager = createExtensionManager([workspaceFolder1.name], context, recordInstances); - registerSpy.mockClear(); - unregisterSpy.mockClear(); + addExtensionSpy.mockClear(); + deleteExtensionSpy.mockClear(); (vscode.window.showQuickPick as any).mockReset(); (vscode.commands.registerCommand as any).mockReset(); }); @@ -280,6 +285,15 @@ describe('ExtensionManager', () => { extensionManager.addExtension(ws1); expect(extensionManager.getByName(ws1.name)).toBeUndefined(); }); + it('will not store instance if extension failed to start up', () => { + (JestExt as jest.Mocked).mockImplementation(() => { + throw new Error('mocked error'); + }); + mockEnabledWorkspaceFolders.mockReturnValue([ws1, ws2]); + extensionManager.applySettings(); + extensionManager.addExtension(ws1); + expect(extensionManager.getByName(ws1.name)).toBeUndefined(); + }); }); describe('deleteExtension', () => { @@ -822,7 +836,7 @@ describe('ExtensionManager', () => { it('will ignore folder change if IgnoreWorkspaceChanges is true', () => { context.workspaceState.get.mockReturnValue(true); extensionManager.onDidChangeWorkspaceFolders(); - expect(registerSpy).not.toHaveBeenCalled(); + expect(addExtensionSpy).not.toHaveBeenCalled(); }); it('should add/remove extensions for the added/removed folders', () => { const f1 = makeWorkspaceFolder('added-1'); @@ -1007,15 +1021,6 @@ describe('ExtensionManager', () => { }); }); - describe('addFolderToDisabledWorkspaceFolders()', () => { - it('should add the folder to the disabledWorkspaceFolders in the configuration', async () => { - addFolderToDisabledWorkspaceFolders('some-workspace-folder'); - expect(updateConfigurationMock).toHaveBeenCalledWith('disabledWorkspaceFolders', [ - 'some-workspace-folder', - ]); - }); - }); - describe.each` files | ext1Call | ext2Call ${['ws-3']} | ${0} | ${0} @@ -1101,14 +1106,17 @@ describe('ExtensionManager', () => { ${'workspace.start'} | ${'startSession'} ${'stop'} | ${'stopSession'} ${'workspace.stop'} | ${'stopSession'} - ${'toggle-coverage'} | ${'toggleCoverageOverlay'} - ${'workspace.toggle-coverage'} | ${'toggleCoverageOverlay'} + ${'toggle-coverage'} | ${'toggleCoverage'} + ${'workspace.toggle-coverage'} | ${'toggleCoverage'} ${'run-all-tests'} | ${'runAllTests'} ${'workspace.run-all-tests'} | ${'runAllTests'} - ${'with-workspace.toggle-auto-run'} | ${'toggleAutoRun'} - ${'with-workspace.toggle-coverage'} | ${'toggleCoverageOverlay'} + ${'with-workspace.change-run-mode'} | ${'changeRunMode'} + ${'workspace.save-run-mode'} | ${'saveRunMode'} + ${'with-workspace.toggle-coverage'} | ${'toggleCoverage'} ${'with-workspace.enable-login-shell'} | ${'enableLoginShell'} ${'with-workspace.item-command'} | ${'runItemCommand'} + ${'with-workspace.exit-defer-mode'} | ${'exitDeferMode'} + ${'with-workspace.setup-extension'} | ${'setupExtensionForFolder'} `('extension-based commands "$name"', async ({ name, extFunc }) => { extensionManager.register(); const expectedName = `${extensionName}.${name}`; @@ -1131,7 +1139,7 @@ describe('ExtensionManager', () => { }); it.each` name | extFunc - ${'editor.workspace.toggle-coverage'} | ${'toggleCoverageOverlay'} + ${'editor.workspace.toggle-coverage'} | ${'toggleCoverage'} ${'editor.workspace.run-all-tests'} | ${'runAllTests'} ${'editor.run-all-tests'} | ${'runAllTests'} ${'editor.debug-tests'} | ${'debugTests'} @@ -1156,7 +1164,52 @@ describe('ExtensionManager', () => { const ext = extensionManager.getByName('ws-2'); expect(ext[extFunc]).toHaveBeenCalled(); }); + it('disable workspace command', async () => { + extensionManager.register(); + const applySettingsSpy = jest.spyOn(extensionManager, 'applySettings'); + (updateSetting as jest.Mocked).mockReturnValue(true); + + const expectedName = `${extensionName}.with-workspace.disable`; + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + expectedName, + expect.anything() + ); + const call = (vscode.commands.registerCommand as jest.Mocked).mock.calls.find( + (args) => args[0] === expectedName + ); + expect(call).not.toBeUndefined(); + + const registeredCallback = call[1]; + await registeredCallback('ws-2'); + expect(updateSetting).toHaveBeenCalled(); + expect(applySettingsSpy).toHaveBeenCalled(); + + // if update failed, no applySettings will be called + (updateSetting as jest.Mocked).mockClear().mockImplementation(() => { + throw new Error('error'); + }); + applySettingsSpy.mockClear(); + + await registeredCallback('ws-2'); + expect(updateSetting).toHaveBeenCalled(); + expect(applySettingsSpy).not.toHaveBeenCalled(); + }); + it('showQuickFix command', async () => { + extensionManager.register(); + const expectedName = `${extensionName}.with-workspace.show-quick-fix`; + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + expectedName, + expect.anything() + ); + const call = (vscode.commands.registerCommand as jest.Mocked).mock.calls.find( + (args) => args[0] === expectedName + ); + expect(call).not.toBeUndefined(); + const registeredCallback = call[1]; + await registeredCallback('ws-2', ['help']); + expect(showQuickFix).toHaveBeenCalled(); + }); it('event handlers', () => { extensionManager.register(); expect(vscode.window.onDidChangeActiveTextEditor).toHaveBeenCalled(); diff --git a/tests/quick-fix.test.ts b/tests/quick-fix.test.ts new file mode 100644 index 000000000..a94e2b24e --- /dev/null +++ b/tests/quick-fix.test.ts @@ -0,0 +1,71 @@ +jest.unmock('../src/quick-fix'); +import * as vscode from 'vscode'; +import { showQuickFix, QuickFixActionType } from '../src/quick-fix'; + +describe('showQuickFix', () => { + beforeEach(() => { + jest.resetAllMocks(); + vscode.Uri.parse = jest.fn().mockImplementation((s) => s); + }); + + it.each` + actionType | command | args + ${'help'} | ${'vscode.open'} | ${'troubleshooting'} + ${'wizard'} | ${'with-workspace.setup-extension'} | ${['folderName', undefined]} + ${'setup-cmdline'} | ${'with-workspace.setup-extension'} | ${['folderName', { taskId: 'cmdLine' }]} + ${'setup-monorepo'} | ${'with-workspace.setup-extension'} | ${['folderName', { taskId: 'monorepo' }]} + ${'disable-folder'} | ${'with-workspace.disable'} | ${['folderName']} + ${'defer'} | ${'with-workspace.change-run-mode'} | ${['folderName']} + ${'help-long-run'} | ${'vscode.open'} | ${'what-to-do-with-long-running-tests-warning'} + `( + 'select actionType "$actionType" will execute command "$command"', + async ({ actionType, command, args }) => { + expect.hasAssertions(); + + vscode.window.showQuickPick = jest + .fn() + .mockImplementationOnce((items) => Promise.resolve(items[0])); + + await showQuickFix('folderName', [actionType]); + if (Array.isArray(args)) { + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + expect.stringContaining(command), + ...args + ); + } else { + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + expect.stringContaining(command), + expect.stringContaining(args) + ); + } + } + ); + it('can display multiple action types and execute the the selected item', async () => { + expect.hasAssertions(); + const actionTypes: QuickFixActionType[] = ['help', 'wizard', 'disable-folder']; + + let items: any[] = []; + vscode.window.showQuickPick = jest.fn().mockImplementationOnce((_items) => { + items = _items; + const wizardItem = items.find((i) => i.label.includes('Customize Extension')); + return Promise.resolve(wizardItem); + }); + + await showQuickFix('a folder', actionTypes); + expect(items).toHaveLength(3); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + expect.stringContaining('with-workspace.setup-extension'), + 'a folder', + undefined + ); + }); + + it('should not execute any action if no item is selected', async () => { + expect.hasAssertions(); + vscode.window.showQuickPick = jest.fn().mockResolvedValue(undefined); + + await showQuickFix('whatever', ['help']); + + expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/terminal-link-provider.test.ts b/tests/terminal-link-provider.test.ts new file mode 100644 index 000000000..783435b1a --- /dev/null +++ b/tests/terminal-link-provider.test.ts @@ -0,0 +1,132 @@ +jest.unmock('../src/terminal-link-provider'); + +import * as vscode from 'vscode'; +import { + ExecutableTerminalLinkProvider, + ExecutableLinkScheme, +} from '../src/terminal-link-provider'; + +describe('ExecutableTerminalLinkProvider', () => { + let provider: ExecutableTerminalLinkProvider; + + beforeEach(() => { + provider = new ExecutableTerminalLinkProvider(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('register', () => { + vscode.window.registerTerminalLinkProvider = jest.fn().mockReturnValueOnce('disposable'); + expect(provider.register()).toEqual('disposable'); + expect(vscode.window.registerTerminalLinkProvider).toHaveBeenCalledWith(provider); + }); + describe('handleTerminalLink', () => { + it('should execute the command with the correct arguments', async () => { + const link: any = { + data: 'whatever', + }; + // with args + vscode.Uri.parse = jest.fn().mockReturnValueOnce({ + authority: 'folderName', + path: '/command', + query: encodeURIComponent(JSON.stringify({ arg1: 'value1', arg2: 'value2' })), + }); + await provider.handleTerminalLink(link); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('command', 'folderName', { + arg1: 'value1', + arg2: 'value2', + }); + + // without args + vscode.Uri.parse = jest.fn().mockReturnValueOnce({ + authority: 'folderName', + path: '/command', + }); + await provider.handleTerminalLink(link); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'command', + 'folderName', + undefined + ); + }); + + it('should show an error message if the link cannot be parsed', async () => { + const link: any = { + data: 'whatever', + }; + vscode.Uri.parse = jest.fn().mockImplementationOnce(() => { + throw new Error('uri parse error'); + }); + await provider.handleTerminalLink(link); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('Failed to handle link "whatever"') + ); + }); + }); + + describe('provideTerminalLinks', () => { + it('should return an empty array if there are no links in the line', () => { + const context: any = { + line: 'some text without links', + }; + const links = provider.provideTerminalLinks(context, undefined); + expect(links).toEqual([]); + }); + + it('should return an array of links if there are links in the line', () => { + const context: any = { + line: `some text with a link ${ExecutableLinkScheme}://folderName/command?${encodeURIComponent( + JSON.stringify({ arg1: 'value1', arg2: 'value2' }) + )} and another link ${ExecutableLinkScheme}://folderName/other-command`, + }; + const links = provider.provideTerminalLinks(context, undefined); + expect(links).toEqual([ + { + startIndex: 22, + length: 92, + tooltip: 'execute command', + data: `${ExecutableLinkScheme}://folderName/command?${encodeURIComponent( + JSON.stringify({ arg1: 'value1', arg2: 'value2' }) + )}`, + }, + { + startIndex: 132, + length: 38, + tooltip: 'execute command', + data: `${ExecutableLinkScheme}://folderName/other-command`, + }, + ]); + }); + it('would returns empty array when match encountered error', () => { + const originalMatchAll = String.prototype.matchAll; + String.prototype.matchAll = jest.fn().mockReturnValueOnce([{ index: undefined }]); + const context: any = { + line: `some text with a link ${ExecutableLinkScheme}://folderName/command`, + }; + const links = provider.provideTerminalLinks(context, undefined); + expect(links).toEqual([]); + + String.prototype.matchAll = originalMatchAll; + }); + }); + + describe('executableLink', () => { + it.each` + seq | folderName | command | args | expectedPath + ${1} | ${'folderName'} | ${'command'} | ${undefined} | ${'//folderName/command'} + ${2} | ${'folderName'} | ${'command'} | ${{ arg1: 'value 1', arg2: 'value 2' }} | ${'//folderName/command'} + ${3} | ${'folder name'} | ${'command-1'} | ${{ arg1: 'value 1', arg2: 'value 2' }} | ${'//folder%20name/command-1'} + `('case $seq: returns an executable link', ({ folderName, command, args, expectedPath }) => { + const link = provider.executableLink(folderName, command, args); + if (args) { + const encodedArgs = encodeURIComponent(JSON.stringify(args)); + expect(link).toEqual(`${ExecutableLinkScheme}:${expectedPath}?${encodedArgs}`); + } else { + expect(link).toEqual(`${ExecutableLinkScheme}:${expectedPath}`); + } + }); + }); +}); diff --git a/tests/test-helper.ts b/tests/test-helper.ts index 7814bc364..33629358c 100644 --- a/tests/test-helper.ts +++ b/tests/test-helper.ts @@ -5,8 +5,8 @@ import { ItBlock, TestAssertionStatus } from 'jest-editor-support'; import { JestProcessRequest } from '../src/JestProcessManagement'; import { JestTestProcessType } from '../src/Settings'; import { MatchEvent } from '../src/TestResults/match-node'; -import { AutoRun } from '../src/JestExt'; import * as path from 'path'; +import { RunMode } from '../src/JestExt/run-mode'; export const EmptyLocation = { line: 0, @@ -158,12 +158,12 @@ export const mockJestExtEvents: any = () => ({ onTestSessionStopped: mockEvent(), }); -export const mockJestExtContext = (autoRun?: AutoRun): any => { +export const mockJestExtContext = (runMode?: RunMode): any => { const baseSettings = { shell: { toSetting: jest.fn() } }; return { workspace: jest.fn(), createRunnerWorkspace: jest.fn(), - settings: autoRun ? { ...baseSettings, autoRun } : baseSettings, + settings: runMode ? { ...baseSettings, runMode } : baseSettings, loggingFactory: { create: jest.fn(() => jest.fn()) }, events: mockJestExtEvents(), }; diff --git a/tests/test-provider/test-helper.ts b/tests/test-provider/test-helper.ts index 6407a278d..e5dc849dd 100644 --- a/tests/test-provider/test-helper.ts +++ b/tests/test-provider/test-helper.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { mockJestExtEvents } from '../test-helper'; +import { RunMode } from '../../src/JestExt/run-mode'; export class TestItemCollectionMock { constructor(public parent?: vscode.TestItem) {} @@ -40,7 +41,7 @@ export const mockExtExplorerContext = (wsName = 'ws-1', override: any = {}): any }, debugTests: jest.fn(), sessionEvents: mockJestExtEvents(), - settings: { testExplorer: { enabled: true }, autoRun: {} }, + settings: { testExplorer: { enabled: true }, runMode: new RunMode() }, output: { write: jest.fn(), dispose: jest.fn(), clear: jest.fn() }, ...override, }; diff --git a/tests/test-provider/test-item-context-manager.test.ts b/tests/test-provider/test-item-context-manager.test.ts index 9a9d57f4b..4f6b667ce 100644 --- a/tests/test-provider/test-item-context-manager.test.ts +++ b/tests/test-provider/test-item-context-manager.test.ts @@ -11,76 +11,31 @@ describe('TestItemContextManager', () => { jest.resetAllMocks(); }); describe('can set itemContext', () => { - describe('jest.autoRun and jest.coverage', () => { - it.each` - case | context | withItemKey | withoutItemKey - ${1} | ${{ key: 'jest.autoRun', value: true, itemIds: ['a'] }} | ${'jest.autoRun.on'} | ${'jest.autoRun.off'} - ${2} | ${{ key: 'jest.autoRun', value: false, itemIds: ['a'] }} | ${'jest.autoRun.off'} | ${'jest.autoRun.on'} - ${3} | ${{ key: 'jest.coverage', value: true, itemIds: ['a'] }} | ${'jest.coverage.on'} | ${'jest.coverage.off'} - ${4} | ${{ key: 'jest.coverage', value: false, itemIds: ['a'] }} | ${'jest.coverage.off'} | ${'jest.coverage.on'} - `('case $case: setContext for $expectedKey', ({ context, withItemKey, withoutItemKey }) => { - const workspace: any = { name: 'ws' }; - const manager = new TestItemContextManager(); - manager.setItemContext({ workspace, ...context }); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - withItemKey, - context.itemIds - ); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - withoutItemKey, - [] - ); - }); + describe('jest.runMode', () => { it('can manage itemContext for multiple workspaces', () => { const ws1: any = { name: 'ws1' }; const ws2: any = { name: 'ws2' }; const manager = new TestItemContextManager(); manager.setItemContext({ workspace: ws1, - key: 'jest.autoRun', - value: true, + key: 'jest.runMode', itemIds: ['a', 'b'], }); manager.setItemContext({ workspace: ws2, - key: 'jest.autoRun', - value: true, + key: 'jest.runMode', itemIds: ['c'], }); manager.setItemContext({ workspace: ws2, - key: 'jest.autoRun', - value: false, + key: 'jest.runMode', itemIds: ['d'], }); - manager.setItemContext({ - workspace: ws2, - key: 'jest.coverage', - value: true, - itemIds: ['c'], - }); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - 'jest.autoRun.on', - ['a', 'b', 'c'] - ); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - 'jest.autoRun.off', - ['d'] - ); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - 'jest.coverage.on', - ['c'] - ); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - 'jest.coverage.off', - [] - ); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'jest.runMode', [ + 'a', + 'b', + 'd', + ]); }); }); describe('jest.editor-view-snapshot', () => { @@ -156,25 +111,25 @@ describe('TestItemContextManager', () => { }); }); describe('can register item menu commands', () => { - it('toggle-autoRun menu commands', () => { + it('trigger runMode menu commands', () => { const manager = new TestItemContextManager(); const disposableList = manager.registerCommands(); expect(disposableList.length).toBeGreaterThanOrEqual(4); const commands = [ - `${extensionName}.test-item.auto-run.toggle-on`, - `${extensionName}.test-item.auto-run.toggle-off`, + `${extensionName}.test-item.run-mode.change`, + `${extensionName}.test-item.run-mode.modified.change`, ]; const calls = (vscode.commands.registerCommand as jest.Mocked).mock.calls.filter( (call) => commands.includes(call[0]) ); // set some itemContext then trigger the menu - const extCmd = `${extensionName}.with-workspace.toggle-auto-run`; + const extCmd = `${extensionName}.with-workspace.change-run-mode`; const workspace: any = { name: 'ws' }; const root = { id: `whatever:${workspace.name}` }; - manager.setItemContext({ workspace, key: 'jest.autoRun', value: true, itemIds: ['a'] }); - expect(calls).toHaveLength(2); + manager.setItemContext({ workspace, key: 'jest.runMode', itemIds: ['a'] }); + expect(calls).toHaveLength(1); calls.forEach((call) => { const callBack = call[1]; callBack({ id: 'a', parent: root }); @@ -186,35 +141,6 @@ describe('TestItemContextManager', () => { expect(vscode.commands.executeCommand).toHaveBeenCalledWith(extCmd, workspace); }); }); - it('toggle-coverage menu commands', () => { - const manager = new TestItemContextManager(); - const disposableList = manager.registerCommands(); - expect(disposableList.length).toBeGreaterThanOrEqual(4); - - const commands = [ - `${extensionName}.test-item.coverage.toggle-on`, - `${extensionName}.test-item.coverage.toggle-off`, - ]; - const calls = (vscode.commands.registerCommand as jest.Mocked).mock.calls.filter( - (call) => commands.includes(call[0]) - ); - - // set some itemContext then trigger the menu - const extCmd = `${extensionName}.with-workspace.toggle-coverage`; - const workspace: any = { name: 'ws' }; - const parent = { id: `whatever:${workspace.name}` }; - manager.setItemContext({ workspace, key: 'jest.coverage', value: false, itemIds: ['a'] }); - expect(calls).toHaveLength(2); - calls.forEach((call) => { - const callBack = call[1]; - callBack({ id: 'a', parent }); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith(extCmd, workspace); - - (vscode.commands.executeCommand as jest.Mocked).mockClear(); - callBack({ id: 'b', parent }); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith(extCmd, workspace); - }); - }); describe('snapshot menu commands', () => { it.each` contextId | contextCommand | itemCommand @@ -223,7 +149,7 @@ describe('TestItemContextManager', () => { `('$contextId', ({ contextId, contextCommand, itemCommand }) => { const manager = new TestItemContextManager(); const disposableList = manager.registerCommands(); - expect(disposableList.length).toBeGreaterThanOrEqual(6); + expect(disposableList.length).toBeGreaterThanOrEqual(4); const calls = (vscode.commands.registerCommand as jest.Mocked).mock.calls.filter( (call) => call[0] === `${extensionName}.${contextCommand}` @@ -304,7 +230,7 @@ describe('TestItemContextManager', () => { // say 2 virtual folders have the same test items const manager = new TestItemContextManager(); const disposableList = manager.registerCommands(); - expect(disposableList.length).toBeGreaterThanOrEqual(6); + expect(disposableList.length).toBeGreaterThanOrEqual(4); const calls = (vscode.commands.registerCommand as jest.Mocked).mock.calls.filter( (call) => call[0] === `${extensionName}.test-item.view-snapshot` @@ -353,7 +279,7 @@ describe('TestItemContextManager', () => { it('if we can not find workspace from testItem, will fallback to the vscode folder', () => { const manager = new TestItemContextManager(); const disposableList = manager.registerCommands(); - expect(disposableList.length).toBeGreaterThanOrEqual(6); + expect(disposableList.length).toBeGreaterThanOrEqual(4); const calls = (vscode.commands.registerCommand as jest.Mocked).mock.calls.filter( (call) => call[0] === `${extensionName}.test-item.view-snapshot` diff --git a/tests/test-provider/test-item-data.test.ts b/tests/test-provider/test-item-data.test.ts index 68a5829e2..cc30e8ff3 100644 --- a/tests/test-provider/test-item-data.test.ts +++ b/tests/test-provider/test-item-data.test.ts @@ -6,6 +6,7 @@ jest.unmock('../../src/TestResults/match-by-context'); jest.unmock('../test-helper'); jest.unmock('./test-helper'); jest.unmock('../../src/errors'); +jest.unmock('../../src/JestExt/run-mode'); import { JestTestRun } from '../../src/test-provider/test-provider-helper'; import { tiContextManager } from '../../src/test-provider/test-item-context-manager'; @@ -46,6 +47,7 @@ import * as path from 'path'; import { mockController, mockExtExplorerContext } from './test-helper'; import * as errors from '../../src/errors'; import { ItemCommand } from '../../src/test-provider/types'; +import { RunMode } from '../../src/JestExt/run-mode'; const mockPathSep = (newSep: string) => { (path as jest.Mocked).setSep(newSep); @@ -64,8 +66,9 @@ const getChildItem = (item: vscode.TestItem, partialId: string): vscode.TestItem const mockScheduleProcess = (context) => { const process: any = { id: 'whatever', request: { type: 'watch-tests' } }; - context.ext.session.scheduleProcess.mockImplementation((request) => { + context.ext.session.scheduleProcess.mockImplementation((request, userData) => { process.request = request; + process.userData = userData; return process; }); return process; @@ -353,7 +356,7 @@ describe('test-item-data', () => { context.ext.testResultProvider.getTestList.mockReturnValueOnce([]); context.ext.settings = { testExplorer: { enabled: true, showInlineError: true }, - autoRun: {}, + runMode: new RunMode('watch'), }; const wsRoot = new WorkspaceRoot(context); @@ -646,8 +649,9 @@ describe('test-item-data', () => { }); describe('when TestExplorer triggered runTest', () => { describe('Each item data can schedule a test run within the session', () => { + let process: any; beforeEach(() => { - context.ext.session.scheduleProcess.mockReturnValue({ id: 'pid' }); + process = mockScheduleProcess(context); }); describe('run request', () => { it('WorkspaceRoot runs all tests in the workspace in blocking-2 queue', () => { @@ -666,7 +670,8 @@ describe('test-item-data', () => { expect.objectContaining({ type: 'by-file-pattern', testFileNamePattern: '/ws-1/folder', - }) + }), + expect.anything() ); }); it('DocumentRoot runs all tests in the test file', () => { @@ -681,7 +686,8 @@ describe('test-item-data', () => { expect.objectContaining({ type: 'by-file-pattern', testFileNamePattern: '/ws-1/a.test.ts', - }) + }), + expect.anything() ); }); it('TestData runs the specific test pattern', () => { @@ -695,7 +701,8 @@ describe('test-item-data', () => { type: 'by-file-test-pattern', testFileNamePattern: uri.fsPath, testNamePattern: 'a test', - }) + }), + expect.anything() ); }); }); @@ -712,10 +719,9 @@ describe('test-item-data', () => { const parent: any = controllerMock.createTestItem('ws-1', 'ws-1', { fsPath: '/ws-1' }); const folderData = new FolderData(context, 'folder', parent); folderData.scheduleTest(jestRun); - const request = context.ext.session.scheduleProcess.mock.calls[0][0]; - expect(request.run).toBe(jestRun); - expect(request.run.item).toBe(folderData.item); + expect(process.userData.run).toBe(jestRun); + expect(process.userData.run.item).toBe(folderData.item); }); it('if test name is not resolved, it will execute the resolved parent test block', () => { const { doc } = createAllTestItems(); @@ -729,9 +735,9 @@ describe('test-item-data', () => { const testItem = new TestData(context, doc.uri, testNode, descItem.item); testItem.scheduleTest(jestRun); - const request = context.ext.session.scheduleProcess.mock.calls[0][0]; - expect(request.run).toBe(jestRun); - expect(request.run.item.id).toBe(doc.item.id); + // const process: any = context.ext.session.scheduleProcess.mock.results[0].value; + expect(process.userData.run).toBe(jestRun); + expect(process.userData.run.item.id).toBe(doc.item.id); // try }); describe('can update snapshot based on runProfile', () => { @@ -745,7 +751,8 @@ describe('test-item-data', () => { expect(context.ext.session.scheduleProcess).toHaveBeenCalledWith( expect.objectContaining({ updateSnapshot: true, - }) + }), + expect.anything() ); }); }); @@ -856,15 +863,14 @@ describe('test-item-data', () => { const dItem = getChildItem(wsRoot.item, 'a.test.ts'); const tItem = getChildItem(dItem, 'test-b'); + expect(vscode.TestMessage).toHaveBeenCalled(); if (hasLocation) { - expect(vscode.TestMessage).toHaveBeenCalled(); expect(runMock.failed).toHaveBeenCalledWith( tItem, expect.objectContaining({ location: {} }), undefined ); } else { - expect(vscode.TestMessage).not.toHaveBeenCalled(); expect(runMock.failed).toHaveBeenCalledWith(tItem, [], undefined); } } @@ -1216,21 +1222,21 @@ describe('test-item-data', () => { `('will use run passed from explorer throughout for $targetItem item', ({ itemType }) => { it('item will be enqueued after schedule', () => { const item = env.scheduleItem(itemType); - expect(process.request.run.vscodeRun.enqueued).toHaveBeenCalledWith(item); + expect(process.userData.run.vscodeRun.enqueued).toHaveBeenCalledWith(item); }); it('item will show started when jest run started', () => { const item = env.scheduleItem(itemType); - process.request.run.vscodeRun.enqueued.mockClear(); + process.userData.run.vscodeRun.enqueued.mockClear(); // scheduled event has no effect env.onRunEvent({ type: 'scheduled', process }); - expect(process.request.run.vscodeRun.enqueued).not.toHaveBeenCalled(); + expect(process.userData.run.vscodeRun.enqueued).not.toHaveBeenCalled(); // starting the process env.onRunEvent({ type: 'start', process }); - expect(process.request.run.item).toBe(item); - expect(process.request.run.vscodeRun.started).toHaveBeenCalledWith(item); + expect(process.userData.run.item).toBe(item); + expect(process.userData.run.vscodeRun.started).toHaveBeenCalledWith(item); //will not create new run expect(controllerMock.createTestRun).not.toHaveBeenCalled(); @@ -1263,10 +1269,10 @@ describe('test-item-data', () => { env.scheduleItem(itemType); env.onRunEvent({ type: 'start', process }); expect(controllerMock.createTestRun).not.toHaveBeenCalled(); - expect(process.request.run.vscodeRun.started).toHaveBeenCalled(); + expect(process.userData.run.vscodeRun.started).toHaveBeenCalled(); env.onRunEvent({ ...event, process }); - expect(process.request.run.vscodeRun.end).not.toHaveBeenCalled(); + expect(process.userData.run.vscodeRun.end).not.toHaveBeenCalled(); expect(runEndSpy).toHaveBeenCalled(); }); @@ -1277,7 +1283,7 @@ describe('test-item-data', () => { env.onRunEvent({ type: 'end', process }); expect(controllerMock.createTestRun).not.toHaveBeenCalled(); - expect(process.request.run.vscodeRun.end).not.toHaveBeenCalled(); + expect(process.userData.run.vscodeRun.end).not.toHaveBeenCalled(); expect(runEndSpy).toHaveBeenCalled(); const error = 'something is wrong'; @@ -1285,7 +1291,7 @@ describe('test-item-data', () => { // no new run need to be created expect(controllerMock.createTestRun).not.toHaveBeenCalled(); - expect(process.request.run.vscodeRun.appendOutput).toHaveBeenCalledWith( + expect(process.userData.run.vscodeRun.appendOutput).toHaveBeenCalledWith( expect.stringContaining(error) ); }); @@ -1469,19 +1475,19 @@ describe('test-item-data', () => { it('run explicit test block', () => { const process: any = mockScheduleProcess(context); const item = env.scheduleItem('testBlock'); - expect(process.request.run).toBe(jestRun); - expect(process.request.run.vscodeRun.enqueued).toHaveBeenCalledWith(item); - expect(process.request.run.item).toBe(item); + expect(process.userData.run).toBe(jestRun); + expect(process.userData.run.vscodeRun.enqueued).toHaveBeenCalledWith(item); + expect(process.userData.run.item).toBe(item); //end the process: will not actually end the run but to only notify the provider env.onRunEvent({ type: 'end', process }); - expect(process.request.run.isClosed()).toBeFalsy(); + expect(process.userData.run.isClosed()).toBeFalsy(); expect(notifyProvider).toHaveBeenCalled(); //the run ends before results come in, the process's run should reflect it pRun.end(); expect(jestRun.isClosed()).toBeTruthy(); - expect(process.request.run.isClosed()).toBeTruthy(); + expect(process.userData.run.isClosed()).toBeTruthy(); // prepare for result processing controllerMock.createTestRun.mockClear(); @@ -1513,19 +1519,19 @@ describe('test-item-data', () => { const item = env.scheduleItem('testBlock'); createTestRunSpy.mockClear(); - expect(process.request.run).toBe(jestRun); - expect(process.request.run.vscodeRun.enqueued).toHaveBeenCalledWith(item); - expect(process.request.run.item).toBe(item); + expect(process.userData.run).toBe(jestRun); + expect(process.userData.run.vscodeRun.enqueued).toHaveBeenCalledWith(item); + expect(process.userData.run.item).toBe(item); //end the process: will not actually end the run but to only notify the provider env.onRunEvent({ type: 'end', process }); - expect(process.request.run.isClosed()).toBeFalsy(); + expect(process.userData.run.isClosed()).toBeFalsy(); expect(notifyProvider).toHaveBeenCalled(); //the parent run ends before test process completes pRun.end(); expect(jestRun.isClosed()).toBeTruthy(); - expect(process.request.run.isClosed()).toBeTruthy(); + expect(process.userData.run.isClosed()).toBeTruthy(); //received more data event: will not create new run env.onRunEvent({ type: 'data', process, raw: 'whatever', text: 'whatever' }); @@ -1549,14 +1555,14 @@ describe('test-item-data', () => { expect(runMock.end).toHaveBeenCalled(); }); }); - describe('extension managed autoRun', () => { + describe('extension managed background runs', () => { let createTestRunSpy; beforeEach(() => { createTestRunSpy = jest.spyOn(context, 'createTestRun'); }); it('watch-test run', () => { const request: any = { type: 'watch-tests' }; - const process = { id: 'whatever', request }; + const process: any = { id: 'whatever', request }; const item = env.wsRoot.item; // starting the process @@ -1568,7 +1574,7 @@ describe('test-item-data', () => { runMock = controllerMock.lastRunMock(); expect(runMock.started).toHaveBeenCalledWith(item); - expect(process.request.run).toBeUndefined(); + expect(process.userData?.run).toBeUndefined(); createTestRunSpy.mockClear(); @@ -1623,7 +1629,8 @@ describe('test-item-data', () => { itemData.runItemCommand(ItemCommand.updateSnapshot); expect(createTestRunSpy).toHaveBeenCalledTimes(1); expect(context.ext.session.scheduleProcess).toHaveBeenCalledWith( - expect.objectContaining({ updateSnapshot: true }) + expect.objectContaining({ updateSnapshot: true }), + expect.anything() ); }); }); diff --git a/tests/test-provider/test-provider-helper.test.ts b/tests/test-provider/test-provider-helper.test.ts index 87a600f1e..8a0533067 100644 --- a/tests/test-provider/test-provider-helper.test.ts +++ b/tests/test-provider/test-provider-helper.test.ts @@ -2,7 +2,7 @@ jest.unmock('../../src/test-provider/test-provider-helper'); jest.unmock('../../src/test-provider/test-provider-helper'); jest.unmock('./test-helper'); -// import * as vscode from 'vscode'; +import * as vscode from 'vscode'; import { JestTestRun } from '../../src/test-provider/test-provider-helper'; import { JestTestProviderContext } from '../../src/test-provider/test-provider-helper'; import { mockController, mockExtExplorerContext } from './test-helper'; @@ -62,4 +62,84 @@ describe('JestTestProviderContext', () => { expect(context.getTag('run')).toEqual(profile.tag); expect(() => context.getTag('debug')).toThrow(); }); + describe('requestFrom', () => { + let context: JestTestProviderContext; + let mockCollection; + + const makeCollection = (items: any[]) => { + const collection: any = { + items: items ?? [], + get: (id) => collection.items.find((i) => i.id === id), + forEach: (callback) => { + collection.items.forEach(callback); + }, + }; + return collection; + }; + const makeItem = (id: string, children?: any[]) => ({ id, children: makeCollection(children) }); + + beforeEach(() => { + jest.resetAllMocks(); + + (vscode.TestRunRequest as jest.Mocked) = jest.fn((include, exclude, profile) => ({ + include, + exclude, + profile, + })); + + const item1 = makeItem('id1', [makeItem('id1-1'), makeItem('id1-2')]); + const item2 = makeItem('id2'); + mockCollection = makeCollection([item1, item2]); + + const controller: any = { items: mockCollection }; + const profiles: any[] = [{ label: 'test' }]; + context = new JestTestProviderContext({} as any, controller, profiles); + }); + + it('should return a new request with included items found in the controller', () => { + const item1 = makeItem('id1-2'); + const item2 = makeItem('id2'); + + const request: any = { include: [item1, item2], profile: { label: 'test' } }; + const newRequest = context.requestFrom(request); + expect(newRequest.include?.map((i) => i.id)).toEqual(['id1-2', 'id2']); + expect(newRequest.exclude).toBeUndefined(); + expect(newRequest.profile.label).toBe('test'); + expect(newRequest).not.toBe(request); + }); + + it('should throw an error if an included item is not found in the controller', () => { + const item1 = makeItem('id3'); + + const request: any = { include: [item1], profile: { label: 'test' } }; + expect(() => context.requestFrom(request)).toThrow('failed to find item'); + }); + + it('should return a new request with excluded items found in the controller', () => { + const item1 = makeItem('id1'); + const item2 = makeItem('id1-2'); + + const request: any = { include: [item1], exclude: [item2], profile: { label: 'test' } }; + const newRequest = context.requestFrom(request); + expect(newRequest.include?.map((i) => i.id)).toEqual(['id1']); + expect(newRequest.exclude?.map((i) => i.id)).toEqual(['id1-2']); + expect(newRequest.profile.label).toBe('test'); + expect(newRequest).not.toBe(request); + }); + + it('should throw an error if an excluded item is not found in the controller', () => { + const item1 = makeItem('id1'); + const item2 = makeItem('id1-3'); + + const request: any = { include: [item1], exclude: [item2], profile: { label: 'test' } }; + expect(() => context.requestFrom(request)).toThrow('failed to find item'); + }); + + it('should throw an error if the profile is not found in the context', () => { + const item1 = makeItem('id1'); + + const request: any = { include: [item1], profile: { label: 'new-profile' } }; + expect(() => context.requestFrom(request)).toThrow('failed to find profile'); + }); + }); }); diff --git a/tests/test-provider/test-provider.test.ts b/tests/test-provider/test-provider.test.ts index 97ee05deb..3a9d2e6d8 100644 --- a/tests/test-provider/test-provider.test.ts +++ b/tests/test-provider/test-provider.test.ts @@ -1,16 +1,18 @@ jest.unmock('../../src/test-provider/test-provider'); jest.unmock('../../src/test-provider/test-provider-helper'); +jest.unmock('../../src/JestExt/run-mode'); jest.unmock('./test-helper'); jest.unmock('../../src/appGlobals'); import * as vscode from 'vscode'; import { JestTestProvider } from '../../src/test-provider/test-provider'; import { WorkspaceRoot } from '../../src/test-provider/test-item-data'; -import { JestTestProviderContext } from '../../src/test-provider/test-provider-helper'; +import { JestTestProviderContext, JestTestRun } from '../../src/test-provider/test-provider-helper'; import { extensionId } from '../../src/appGlobals'; import { mockController, mockExtExplorerContext } from './test-helper'; import { tiContextManager } from '../../src/test-provider/test-item-context-manager'; import { ItemCommand } from '../../src/test-provider/types'; +import { RunMode } from '../../src/JestExt/run-mode'; const throwError = () => { throw new Error('debug error'); @@ -85,15 +87,23 @@ describe('JestTestProvider', () => { [vscode.TestRunProfileKind.Run, 'run', true], [vscode.TestRunProfileKind.Debug, 'debug', true], ].forEach(([kind, id, isDefault]) => { - expect(controllerMock.createRunProfile).toHaveBeenCalledWith( - expect.anything(), - kind, - expect.anything(), - isDefault, - expect.objectContaining({ id }) - ); + const profile = controllerMock.createRunProfile.mock.results.find( + (r) => r.value.label === id + )?.value; + expect(profile.kind).toEqual(kind); + expect(profile.isDefault).toEqual(isDefault); + if (id === 'run') { + expect(profile.configureHandler).toBeDefined(); + profile.configureHandler(); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + expect.stringContaining(`with-workspace.change-run-mode`), + extExplorerContextMock.workspace + ); + } else { + expect(profile.configureHandler).not.toBeDefined(); + } }); - + controllerMock.createRunProfile.mock.results.find((r) => r.label === 'run'); expect(WorkspaceRoot).toHaveBeenCalled(); }); }); @@ -485,57 +495,79 @@ describe('JestTestProvider', () => { await p; expect(runMock.end).toHaveBeenCalledTimes(1); }); - it('can handle exception', async () => { - expect.hasAssertions(); - - const testProvider = new JestTestProvider(extExplorerContextMock); - const itemDataList = setupItemData(workspaceRootMock.context); - itemDataList.forEach((d, idx) => { - if (idx === 1) { - d.scheduleTest.mockImplementation(() => { - throw new Error('error scheduling test'); - }); - } else { - d.scheduleTest.mockReturnValueOnce(`pid-${idx}`); - } - }); - const request: any = { - include: itemDataList.map((d) => d.item), - profile: { kind: vscode.TestRunProfileKind.Run }, - }; - const p = testProvider.runTests(request, cancelToken); + describe('can handle exception', () => { + it('when schedule test failed', async () => { + expect.hasAssertions(); - // cancel after run - cancelToken.isCancellationRequested = true; + const testProvider = new JestTestProvider(extExplorerContextMock); + const itemDataList = setupItemData(workspaceRootMock.context); + itemDataList.forEach((d, idx) => { + if (idx === 1) { + d.scheduleTest.mockImplementation(() => { + throw new Error('error scheduling test'); + }); + } else { + d.scheduleTest.mockReturnValueOnce(`pid-${idx}`); + } + }); + const request: any = { + include: itemDataList.map((d) => d.item), + profile: { kind: vscode.TestRunProfileKind.Run }, + }; + const p = testProvider.runTests(request, cancelToken); - // a run is already created - expect(controllerMock.createTestRun).toHaveBeenCalled(); - const runMock = controllerMock.lastRunMock(); + // cancel after run + cancelToken.isCancellationRequested = true; - itemDataList.forEach((d, idx) => { - expect(d.scheduleTest).toHaveBeenCalled(); - const [run] = d.scheduleTest.mock.calls[0]; - expect(run).toEqual(expect.objectContaining({ vscodeRun: controllerMock.lastRunMock() })); + // a run is already created + expect(controllerMock.createTestRun).toHaveBeenCalled(); + const runMock = controllerMock.lastRunMock(); - if (idx === 1) { - expect(run.vscodeRun.errored).toHaveBeenCalledWith( - d.item, - expect.anything(), - undefined + itemDataList.forEach((d, idx) => { + expect(d.scheduleTest).toHaveBeenCalled(); + const [run] = d.scheduleTest.mock.calls[0]; + expect(run).toEqual( + expect.objectContaining({ vscodeRun: controllerMock.lastRunMock() }) ); - } else { - expect(run.vscodeRun.errored).not.toHaveBeenCalledWith( - d.item, - expect.anything(), - undefined - ); - // close the schedule - run.end(); - } + + if (idx === 1) { + expect(run.vscodeRun.errored).toHaveBeenCalledWith( + d.item, + expect.anything(), + undefined + ); + } else { + expect(run.vscodeRun.errored).not.toHaveBeenCalledWith( + d.item, + expect.anything(), + undefined + ); + // close the schedule + run.end(); + } + }); + + await p; + expect(runMock.end).toHaveBeenCalled(); }); + it('when debug failed', async () => { + expect.hasAssertions(); - await p; - expect(runMock.end).toHaveBeenCalled(); + const testProvider = new JestTestProvider(extExplorerContextMock); + const debugSpy = jest.spyOn(testProvider, 'debugTest'); + debugSpy.mockImplementation(() => { + throw new Error('force an error'); + }); + const writeSpy = jest.spyOn(JestTestRun.prototype, 'write'); + const itemDataList = setupItemData(workspaceRootMock.context); + const request: any = { + include: itemDataList.map((d) => d.item), + profile: { kind: vscode.TestRunProfileKind.Debug, label: 'debug' }, + }; + await testProvider.runTests(request, cancelToken); + expect(debugSpy).toHaveBeenCalled(); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('debug'), 'error'); + }); }); it('if no item in request, will run test for the whole workplace', async () => { expect.hasAssertions(); @@ -570,23 +602,11 @@ describe('JestTestProvider', () => { }); describe('supports test-explorer item-menu', () => { it('updates item-menu context', () => { - extExplorerContextMock.settings.autoRun.isOff = false; - extExplorerContextMock.settings.showCoverageOnLoad = false; - new JestTestProvider(extExplorerContextMock); expect(tiContextManager.setItemContext).toHaveBeenCalledWith( expect.objectContaining({ workspace: extExplorerContextMock.workspace, - key: 'jest.autoRun', - value: true, - itemIds: [workspaceRootMock.item.id], - }) - ); - expect(tiContextManager.setItemContext).toHaveBeenCalledWith( - expect.objectContaining({ - workspace: extExplorerContextMock.workspace, - key: 'jest.coverage', - value: false, + key: 'jest.runMode', itemIds: [workspaceRootMock.item.id], }) ); @@ -604,4 +624,23 @@ describe('JestTestProvider', () => { provider.runItemCommand(workspaceRootMock.item, ItemCommand.updateSnapshot); expect(workspaceRootMock.runItemCommand).toHaveBeenCalled(); }); + it('will exit defer mode upon any run request', async () => { + expect.hasAssertions(); + + const runMode = new RunMode({ type: 'on-demand', deferred: true }); + extExplorerContextMock.settings.runMode = runMode; + const provider = new JestTestProvider(extExplorerContextMock); + + const request: any = { + include: {}, + profile: { kind: vscode.TestRunProfileKind.Debug }, + }; + await provider.runTests(request, {} as any); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + expect.stringContaining('with-workspace.exit-defer-mode'), + extExplorerContextMock.workspace, + expect.objectContaining({ request }) + ); + expect(controllerMock.createTestRun).not.toHaveBeenCalled(); + }); }); diff --git a/webpack.config.js b/webpack.config.js index ee508fca3..b5779fab4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,7 +1,6 @@ 'use strict'; const path = require('path'); -// const IgnoreDynamicRequire = require('webpack-ignore-dynamic-require'); /**@returns {import('webpack').Configuration}*/ module.exports = () => { diff --git a/yarn.lock b/yarn.lock index dbbe4bf4d..bbdb5ff68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -321,6 +321,13 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + "@discoveryjs/json-ext@^0.5.0": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" @@ -631,6 +638,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" + integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== + "@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" @@ -649,6 +661,14 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.14", "@jridgewell/trace-mapping@^0.3.15", "@jridgewell/trace-mapping@^0.3.9": version "0.3.17" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985" @@ -702,6 +722,26 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + "@types/babel__core@^7.1.14": version "7.1.20" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.20.tgz#e168cdd612c92a2d335029ed62ac94c95b362359" @@ -761,6 +801,14 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== +"@types/fs-extra@^11.0.2": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.2.tgz#23dc1ed7b2eba8ccd75568ac34e7a4e48aa2d087" + integrity sha512-c0hrgAOVYr21EX8J0jBMXGLMgJqVf/v6yxi0dLaJboW9aQPh16Id+z6w2Tx1hm+piJOLv8xPfVKZCLfjPw/IMQ== + dependencies: + "@types/jsonfile" "*" + "@types/node" "*" + "@types/graceful-fs@^4.1.2", "@types/graceful-fs@^4.1.3": version "4.1.6" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" @@ -808,6 +856,13 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== +"@types/jsonfile@*": + version "6.1.2" + resolved "https://registry.yarnpkg.com/@types/jsonfile/-/jsonfile-6.1.2.tgz#d3b8a3536c5bb272ebee0f784180e456b7691c8f" + integrity sha512-8t92P+oeW4d/CRQfJaSqEwXujrhH4OEeHRjGU3v1Q8mUS8GPF3yiX26sw4svv6faL2HfBtGTe2xWIoVgN3dy9w== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@^18.11.18": version "18.11.18" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f" @@ -1096,6 +1151,16 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== +acorn-walk@^8.1.1: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + +acorn@^8.4.1: + version "8.10.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" + integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== + acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0: version "8.8.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73" @@ -1162,6 +1227,11 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1482,6 +1552,11 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -1528,6 +1603,11 @@ diff-sequences@^29.3.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.3.1.tgz#104b5b95fe725932421a9c6e5b4bef84c3f2249e" integrity sha512-hlM3QR272NXCi4pq+N4Kok4kOp6EsgOM3ZSpJI7Da3UAs+Ttsi8MRmB6trM/lhyzUxGfOgnpkHtgqm5Q/CTcfQ== +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -1911,6 +1991,15 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== +fs-extra@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d" + integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -2016,6 +2105,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.2.2, graceful-fs@^4.2.4, graceful-fs@^4.2.9: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + grapheme-splitter@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" @@ -2777,6 +2871,15 @@ json5@^2.1.2, json5@^2.2.1, json5@^2.2.2: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -2869,7 +2972,7 @@ make-dir@^3.0.0: dependencies: semver "^6.0.0" -make-error@1.x: +make-error@1.x, make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== @@ -3561,6 +3664,25 @@ ts-loader@^9.4.2: micromatch "^4.0.0" semver "^7.3.4" +ts-node@^10.9.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -3607,6 +3729,11 @@ typescript@^4.9.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + unzipper@^0.10.11: version "0.10.11" resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e" @@ -3643,6 +3770,11 @@ util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + v8-to-istanbul@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz#b6f994b0b5d4ef255e17a0d17dc444a9f5132fa4" @@ -3811,6 +3943,11 @@ yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.1.1" +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"