├── .actrc ├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.yml ├── .gitattributes ├── .github ├── Get-CalendarVersion.ps1 ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── ci.yml │ └── codeql-analysis.yml ├── .gitignore ├── .mocharc.yml ├── .npmrc ├── .prettierignore ├── .prettierrc.yml ├── .vscode-test.js ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CONTRIBUTING.md ├── GitVersion.yml ├── LICENSE ├── README.md ├── Scripts ├── PesterInterface.Tests.ps1 ├── PesterInterface.ps1 ├── PesterTestPlugin.psm1 ├── powershellRunner.Tests.ps1 └── powershellRunner.ps1 ├── images ├── 2021-08-07-08-06-26.png ├── pesterlogo.png ├── pesterlogo.svg ├── social-preview.pdn └── social-preview.png ├── package.json ├── patches └── @vscode__test-cli@0.0.4.patch ├── pnpm-lock.yaml ├── sample ├── Tests.2 │ ├── Empty.Tests.ps1 │ └── True.Tests.ps1 ├── Tests.3 │ ├── Test3Unique.Tests.ps1 │ └── True.Tests.ps1 ├── Tests.code-workspace └── Tests │ ├── Basic.Tests.ps1 │ ├── BeforeAllError.Tests.ps1 │ ├── ContextSyntaxError.Tests.ps1 │ ├── DescribeSyntaxError.Tests.ps1 │ ├── DuplicateTestInfo.Tests.ps1 │ ├── EdgeCasesAndRegressions.Tests.ps1 │ ├── Empty.Tests.ps1 │ ├── Mocks │ ├── BasicTests.json │ ├── Block.clixml │ ├── MockResult.json │ └── StrictMode.ps1 │ ├── StrictMode.Tests.ps1 │ ├── Test With Space.Tests.ps1 │ └── True.Tests.ps1 ├── src ├── dotnetNamedPipeServer.ts ├── extension.ts ├── features │ └── toggleAutoRunOnSaveCommand.ts ├── log.ts ├── log.vscode.test.ts ├── pesterTestController.ts ├── pesterTestTree.ts ├── powershell.test.ts ├── powershell.ts ├── powershell.types.ts ├── powershellExtensionClient.ts ├── stripAnsiStream.ts ├── util │ └── testItemUtils.ts └── workspaceWatcher.ts ├── test ├── TestEnvironment.code-workspace └── extension.test.ts └── tsconfig.json /.actrc: -------------------------------------------------------------------------------- 1 | -P ubuntu-20.04=ghcr.io/justingrote/act-pwsh-dotnet:latest 2 | -P ubuntu-latest=ghcr.io/justingrote/act-pwsh-dotnet:latest -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | "ghcr.io/devcontainers/features/powershell:1": {}, 5 | "ghcr.io/devcontainers/features/node:1": {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | vscode.d.ts 2 | vscode.proposed.d.ts 3 | dist 4 | node_modules 5 | .vscode-test 6 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - eslint:recommended 3 | - plugin:@typescript-eslint/recommended 4 | # - standard-with-typescript 5 | - prettier #Disables all rules that conflict with prettier 6 | parser: "@typescript-eslint/parser" 7 | plugins: 8 | - "@typescript-eslint" 9 | rules: 10 | no-tabs: off 11 | indent: off 12 | "@typescript-eslint/indent": off 13 | "@typescript-eslint/space-before-function-paren": off 14 | "@typescript-eslint/explicit-function-return-type": off 15 | "no-return-await": off 16 | "@typescript-eslint/return-await": off 17 | "@typescript-eslint/no-unused-vars": warn 18 | "@typescript-eslint/restrict-template-expressions": off 19 | env: 20 | jest: true 21 | node: true 22 | es2021: true 23 | overrides: 24 | - files: 25 | - "**/*.test.ts" 26 | rules: 27 | "@typescript-eslint/no-unused-expressions": off 28 | parserOptions: 29 | project: ./tsconfig.json 30 | tsconfigRootDir: . 31 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # We'll let Git's auto-detection algorithm infer if a file is text. If it is, 2 | # enforce LF line endings regardless of OS or git configurations. 3 | * text=auto eol=lf 4 | -------------------------------------------------------------------------------- /.github/Get-CalendarVersion.ps1: -------------------------------------------------------------------------------- 1 | #requires -version 7 2 | using namespace System.Management.Automation 3 | Set-StrictMode -Version 3 4 | $ErrorActionPreference = 'Stop' 5 | 6 | <# 7 | .SYNOPSIS 8 | Generates a CalendarVersion based on the current commit 9 | #> 10 | function Get-CalendarVersion { 11 | param( 12 | #The branch where releases are produced. Untagged releases will have the "beta" label 13 | [string]$releaseBranchName = 'main', 14 | #Add the build number to the release number. Basically replace the "+" with a "-" 15 | [switch]$MergeBuild 16 | ) 17 | $date = [DateTime]::Now 18 | [string]$datePrefix = $date.Year, $date.Month -join '.' 19 | 20 | #This version component is zero-based so it should be 0 for the first release of the month, 1 for the second, etc. 21 | $releaseCount = @(& git tag -l "v$DatePrefix*").count 22 | 23 | if ($releaseBranchName -eq [string](git describe --tags)) { 24 | return [SemanticVersion]::new($date.Year, $date.Month, $releaseCount) 25 | } 26 | 27 | [string]$currentBranchName = & git branch --show-current 28 | if (-not $currentBranchName -and $env:GITHUB_REF) { 29 | $currentBranchName = $env:GITHUB_REF 30 | } 31 | 32 | Write-Verbose "Current Branch Name: $currentBranchName" 33 | 34 | [string]$branchName = if ($currentBranchName -eq $releaseBranchName) { 35 | 'beta' 36 | } elseif ($currentBranchName -match '^refs/pull/(\d+)/merge$') { 37 | 'pr' + $matches[1] 38 | Write-Verbose "Pull Request Branch Detected, branchname is now pr$($matches[1])" 39 | } else { 40 | $currentBranchName.split('/') | Select-Object -Last 1 41 | } 42 | 43 | $delimiter = $MergeBuild ? '+' : '-' 44 | [int]$commitsSince = @(& git log --oneline -- "$currentBranchName..HEAD").count 45 | [string]$prereleaseTag = $branchName, $commitsSince.ToString().PadLeft(3, '0') -join $delimiter 46 | 47 | return [SemanticVersion]::new($date.Year, $date.Month, $releaseCount, $prereleaseTag) 48 | } 49 | 50 | Get-CalendarVersion 51 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | dependencies: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | groups: 16 | dependencies: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 New Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - title: '🐞 Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - 'bugfix' 12 | - 'bug' 13 | - title: '🧰 Build Improvements and Maintenace' 14 | labels: 15 | - 'chore' 16 | - 'build' 17 | - title: '📝 Documentation Updates' 18 | labels: 19 | - 'documentation' 20 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 21 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 22 | template: | 23 | $CHANGES 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | push: 7 | branches: 8 | - main 9 | 10 | name: 👷‍♂️ Build Visual Studio Code Extension 11 | 12 | jobs: 13 | build: 14 | name: 👷‍♂️ Build 15 | defaults: 16 | run: 17 | shell: pwsh 18 | env: 19 | DOTNET_CLI_TELEMETRY_OPTOUT: true 20 | runs-on: ubuntu-20.04 21 | steps: 22 | - name: 🚚 Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: 📥 Cache 28 | id: cache 29 | uses: actions/cache@v4 30 | with: 31 | path: | 32 | node_modules 33 | .vscode-test 34 | key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }} 35 | 36 | - name: ➕ Dependencies 37 | uses: pnpm/action-setup@v4.0.0 38 | with: 39 | run_install: true 40 | 41 | - name: 🔍 Get Calendar Version 42 | id: version 43 | run: | 44 | git config --global --add safe.directory $PWD 45 | dir env: 46 | [string]$version = & .github\Get-CalendarVersion.ps1 -MergeBuild 47 | "Calculated Version: $version" 48 | "version=$version" >> $ENV:GITHUB_OUTPUT 49 | "calendarVersion=$version" >> $ENV:GITHUB_OUTPUT 50 | "semanticVersion=$version" >> $ENV:GITHUB_OUTPUT 51 | 52 | - name: 👷‍♂️ Build and Package 53 | run: | 54 | pnpm package ${{steps.version.outputs.version}} 55 | 56 | - name: ⬆ Artifact 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: Pester Tests VSCode Extension 60 | path: '*.vsix' 61 | 62 | - name: 📝 Draft Github Release 63 | if: github.ref == 'refs/heads/main' || github.head_ref == 'ci' 64 | uses: release-drafter/release-drafter@v6 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | with: 68 | version: '${{steps.version.outputs.version}}' 69 | prerelease: true 70 | tag: 'v${{steps.version.outputs.version}}' 71 | 72 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 🔎 CodeQL 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | 21 | jobs: 22 | analyze: 23 | name: Analyze 24 | runs-on: ubuntu-latest 25 | permissions: 26 | actions: read 27 | contents: read 28 | security-events: write 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | language: ['javascript'] 34 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 35 | # Learn more: 36 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 37 | 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | 42 | # Initializes the CodeQL tools for scanning. 43 | - name: Initialize CodeQL 44 | uses: github/codeql-action/init@v3 45 | with: 46 | languages: ${{ matrix.language }} 47 | # If you wish to specify custom queries, you can do so here or in a config file. 48 | # By default, queries listed here will override any specified in a config file. 49 | # Prefix the list here with "+" to use these queries and those in the config file. 50 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 51 | 52 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 53 | # If this step fails, then you should remove it and run the build manually (see below) 54 | - name: Autobuild 55 | uses: github/codeql-action/autobuild@v3 56 | 57 | # ℹ️ Command-line programs to run using the OS shell. 58 | # 📚 https://git.io/JvXDl 59 | 60 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 61 | # and modify them (or add more) to build your code if your project 62 | # uses a compiled language 63 | 64 | #- run: | 65 | # make bootstrap 66 | # make release 67 | 68 | - name: Perform CodeQL Analysis 69 | uses: github/codeql-action/analyze@v3 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | /vscode.d.ts 7 | /vscode.proposed.d.ts 8 | test-results.xml 9 | -------------------------------------------------------------------------------- /.mocharc.yml: -------------------------------------------------------------------------------- 1 | require: 'esbuild-register' 2 | spec: 3 | - 'src/**/*.test.ts' 4 | - 'test/**/*.test.ts' 5 | ignore: ['**/*.vscode.test.ts'] 6 | exit: true 7 | failZero: true 8 | color: true 9 | enable-source-maps: true 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Dont forget to update this in package.json script esbuild-base too! 2 | use-node-version=18.15.0 3 | engine-strict=true 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | package.json 4 | package-lock.json 5 | pnpm-lock.yaml 6 | .vscode-test 7 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: none 3 | arrowParens: avoid 4 | semi: false 5 | tabWidth: 2 6 | useTabs: true 7 | printWidth: 120 8 | -------------------------------------------------------------------------------- /.vscode-test.js: -------------------------------------------------------------------------------- 1 | // .vscode-test.js 2 | const { defineConfig } = require('@vscode/test-cli') 3 | 4 | module.exports = defineConfig({ 5 | files: 'dist/test/**/*.vscode.test.js', 6 | launchArgs: ['--profile=vscode-pester-test'], 7 | mocha: { 8 | ui: 'bdd', 9 | timeout: 600000 // 10 minutes to allow for debugging 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-vscode.powershell", 4 | "github.vscode-pull-request-github", 5 | "github.vscode-github-actions", 6 | "dbaeumer.vscode-eslint", 7 | "connor4312.esbuild-problem-matchers", 8 | "hbenl.vscode-mocha-test-adapter", 9 | "ms-vscode.extension-test-runner" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}", 14 | "--profile=Debug-PesterExtension", 15 | "--trace-warnings", 16 | "${workspaceFolder}/test/TestEnvironment.code-workspace" 17 | ], 18 | "env": { 19 | "VSCODE_DEBUG_MODE": "true" 20 | }, 21 | "presentation": { 22 | "group": "_build" 23 | }, 24 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 25 | "sourceMaps": true, 26 | "autoAttachChildProcesses": true, 27 | "skipFiles": ["/**", "**/extensionHostProcess.js", "**/.vscode/extensions/**"], 28 | "showAsyncStacks": true, 29 | "smartStep": true, 30 | "preLaunchTask": "build-watch" 31 | }, 32 | { 33 | "name": "Run Extension Multi-Root Workspace", 34 | "type": "extensionHost", 35 | "request": "launch", 36 | "args": [ 37 | "--extensionDevelopmentPath=${workspaceFolder}", 38 | "--profile=Debug-PesterExtension", 39 | "--trace-warnings", 40 | "${workspaceFolder}/sample/Tests.code-workspace" 41 | ], 42 | "env": { 43 | "VSCODE_DEBUG_MODE": "true" 44 | }, 45 | "presentation": { 46 | "group": "_build" 47 | }, 48 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 49 | "sourceMaps": true, 50 | "autoAttachChildProcesses": true, 51 | "skipFiles": ["/**", "**/extensionHostProcess.js", "**/.vscode/extensions/**"], 52 | "showAsyncStacks": true, 53 | "smartStep": true, 54 | "preLaunchTask": "build-watch" 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "github-actions.workflows.pinned.workflows": [".github/workflows/ci.yml"], 3 | "editor.detectIndentation": false, 4 | "pester.testFilePath": ["Scripts/PesterInterface.[tT]ests.[pP][sS]1"], 5 | "typescript.format.semicolons": "remove", // Some actions ignore ESLint 6 | "[typescript]": { 7 | "editor.defaultFormatter": "vscode.typescript-language-features" 8 | }, 9 | "mochaExplorer.logpanel": true, 10 | "testExplorer.useNativeTesting": true, 11 | "extension-test-runner.debugOptions": { 12 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"] 13 | }, 14 | "mochaExplorer.env": { 15 | "VSCODE_VERSION": "insiders", 16 | "ELECTRON_RUN_AS_NODE": null 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build-watch", 6 | "icon": { 7 | "color": "terminal.ansiBlue", 8 | "id": "sync" 9 | }, 10 | "type": "npm", 11 | "script": "build-watch", 12 | "group": "build", 13 | "problemMatcher": "$esbuild-watch", 14 | "isBackground": true 15 | }, 16 | { 17 | "label": "build-test-vscode-watch", 18 | "icon": { 19 | "color": "terminal.ansiBlue", 20 | "id": "beaker" 21 | }, 22 | "type": "npm", 23 | "script": "build-test-vscode-watch", 24 | "group": "build", 25 | "problemMatcher": "$esbuild-watch", 26 | "isBackground": true 27 | }, 28 | { 29 | "label": "test-watch", 30 | "icon": { 31 | "color": "terminal.ansiBlue", 32 | "id": "beaker" 33 | }, 34 | "type": "npm", 35 | "script": "test-watch", 36 | "group": "test", 37 | "isBackground": true, 38 | "problemMatcher": { 39 | "owner": "typescript", 40 | "source": "mocha", 41 | "pattern": { 42 | "regexp": "\\w+?(Error): (.+)\\w+?\\((.+)\\)", 43 | "severity": 1, 44 | "message": 2, 45 | "file": 3 46 | }, 47 | "severity": "info", 48 | "fileLocation": ["relative", "${workspaceFolder}"], 49 | "background": { 50 | "activeOnStart": true, 51 | "beginsPattern": " ", 52 | "endsPattern": { 53 | "regexp": "^ℹ \\[mocha\\] waiting for changes...$" 54 | } 55 | } 56 | } 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | ** 2 | !dist/extension.js 3 | !LICENSE 4 | !Scripts 5 | !images/pesterlogo.png 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Welcome! There are many ways you can contribute to the extension 2 | 3 | # Documentation / Non-Code Changes 4 | 5 | I recommend you use the Remote Repositories extension to check this repository out in VSCode, make adjustments, and 6 | submit a pull request, all without ever having to clone the repository locally. 7 | 8 | TODO: "Peek with VSCode Remote Repositories" button 9 | 10 | # Development 11 | 12 | The extension is broken into the following abstraction layers: 13 | 14 | 1. **Extension** - The main extension, verifies prerequisites and registers the test controller 15 | 1. **PesterTestController** - An implementation of the vscode test controller 16 | 1. **PesterTestTree** - A "live object" model that represents the tests to run and their organization. For now they follow 17 | the Pester hierarchy (File/Describe/Context/It) though additional hierarchies (Group by Tag/Function/etc) are planned 18 | 1. **PesterTestRunner** - Contains methods for running pester tests, used by Pester Test Controller 19 | 1. **PowerShellRunner** - Contains methods for running powershell scripts, used by PesterTestRunner 20 | 21 | ## Resolve dependencies 22 | 23 | In the local repository install the dependencies by running the following (answer _yes_ on all questions): 24 | 25 | ```bash 26 | npm run install 27 | ``` 28 | 29 | This needs to be repeated everytime the packages in the `package.json` is modified, those packages under the names `devDependencies` and `dependencies`. 30 | 31 | ## Build project 32 | 33 | There is normally no need to manually build the project (see [Running Tests](#running-test) instead), but it is posisble from command line or from 34 | Visual Studio code. 35 | 36 | In both scenarios, answer _yes_ on the question about creating a license. 37 | 38 | ### Using command line 39 | 40 | ```bash 41 | # Generates version 0.0.0 42 | npm run build 43 | ``` 44 | 45 | ```bash 46 | # Generates version 12.0.0 47 | npm run build v12.0.0 48 | ``` 49 | 50 | The version number of the extension. Can be any version number for testing to install and 51 | use the extension in VS Code. 52 | 53 | ### Using Visual Studio Code 54 | 55 | Open the Command Palette, then choose _Task: Run Task_. In the drop down list choose _npm: build_. Faster yet, choose the _Task: Run Build Task_ in the Command Palette. 56 | 57 | ## Start debug session 58 | 59 | To start a debug session just press the key **F5**, or in Visual Studio Code go to _Run and Debug_ and click on the green arrow to the left of _Run Extension_. 60 | 61 | This will build, start the _watch_ task, and run a new _second_ instance of Visual Studio Code side-by-side, and have the extension loaded. It will also open the 'sample' folder 62 | in the workspace. The 'sample' is part of the Pester Test Adapter project. Once the second instance of Visual Studio Code is started it is possible to close the default folder 63 | and open another workspace or folder. When the second instance of Visual Studio Code is closed the debug session ends. 64 | 65 | This project uses the Jest test framework with the esbuild-jest plugin to quickly compile the typescript for testing. 66 | The task `watch` will start (`npm run watch`) in the background that watches for code changes (saved files) an does a rebuild. 67 | 68 | ### Setting breakpoints 69 | 70 | During the debug session you can set breakpoints in the code as per usual, and you can step through the code. 71 | 72 | ### Debug logging 73 | 74 | Debug logging from the extension can be found in the _Debug Console_ in the first instance of Visual Studio Code. 75 | 76 | ### Debug PowerShell scripts 77 | 78 | It is not possible to debug the PowerShell scripts that the extension runs in the debug session (explained above). Instead the scripts have to be manually run in the PSIC, for 79 | example copy the "one-liner" to be run from the _Debug Console_ window and run it manually. Before running the scripts manully it is possible to set breakpoints in the PowerShell 80 | scripts which then will be hit. 81 | 82 | ## Running Tests 83 | 84 | There are also test that can be run from the command line using: 85 | 86 | ```bash 87 | npm run tests 88 | ``` 89 | -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | mode: ContinuousDeployment 2 | branches: 3 | main: 4 | tag: beta 5 | increment: Patch 6 | release: 7 | tag: rc 8 | pull-request: 9 | tag: pr 10 | 11 | #Custom commit messages to support Conventional Commit and DevMoji syntax 12 | 13 | #Reference: https://regex101.com/r/xdUFkI/1 14 | major-version-bump-message: 💥|:boom:|BREAKING CHANGE:|\+semver:\s?(breaking|major) 15 | #Reference: https://regex101.com/r/hegWXh/1 16 | minor-version-bump-message: ✨|:(feat|tada):|^feat:|\+semver:\s?(feature|minor) 17 | #Reference: https://regex101.com/r/NACNiA/1 18 | patch-version-bump-message: '[📌🐛🩹🚑♻️🗑️🔥⚡🔒➕➖🔗⚙️]|:(bug|refactor|perf|security|add|remove|deps|config):|^(fix|refactor|perf|security|style|deps):|\+semver:\s?(fix|patch)' 19 | #Reference: https://regex101.com/r/Kw8oen/1 20 | no-bump-message: '[📝📚🎨🚨💡🧪✔️✅☑️🚀📦👷🌐🔧]|:(docs|style|test|test_tube|release|build|ci|i18n|chore|heavy_check_mark|white_check_mark|ballot_box_with_check):|^(docs|style|test|release|build|ci|i18n|chore):|\+semver:\s?(none|skip)' 21 | 22 | #Set the build numbers to be xxx, example 0.1.0-myfeature001 or 0.2.1-beta001. This allows for longer feature branch names, and can be increased for more active projects 23 | #You should set this to the number of commits you expect to have for a given branch before merging. 24 | #For instance, if vNext is never going to contain more than 99 commits before you push it to master as a new version, you can set this to 2. 3 is good for all but the largest projects. 25 | #BUG: Cannot use anything other than 4 until this is fixed: https://github.com/GitTools/GitVersion/issues/2632 26 | legacy-semver-padding: 4 27 | build-metadata-padding: 4 28 | commits-since-version-source-padding: 4 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright 2022 Justin Grote @JustinWGrote 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Pester Test Adapter for Visual Studio Code](images/social-preview.png)](https://marketplace.visualstudio.com/items?itemName=pspester.pester-test) 2 | [![Latest](https://img.shields.io/visual-studio-marketplace/v/pspester.pester-test?style=flat-square)](https://marketplace.visualstudio.com/items?itemName=pspester.pester-test) 3 | [![Installs](https://img.shields.io/visual-studio-marketplace/i/pspester.pester-test?style=flat-square)](https://marketplace.visualstudio.com/items?itemName=pspester.pester-test) 4 | [![vsix](https://img.shields.io/github/v/release/pester/vscode-adapter?label=vsix&sort=semver&style=flat-square)](https://github.com/pester/vscode-adapter/releases) 5 | [![Build](https://img.shields.io/github/workflow/status/pester/vscode-adapter/ci.yml?style=flat-square)](https://github.com/pester/vscode-adapter/actions/workflows/ci.yml) 6 | [![Analysis](https://img.shields.io/github/workflow/status/pester/vscode-adapter/codeql-analysis.yml/main?label=codeQL&style=flat-square)](https://github.com/pester/vscode-adapter/actions/workflows/codeql-analysis.yml) 7 | 8 | [![License: MIT](https://img.shields.io/npm/l/tslog?logo=tslog&style=flat-square)](https://tldrlegal.com/license/mit-license) 9 | [![GitHub stars](https://img.shields.io/github/stars/pester/vscode-adapter?style=social)](https://github.com/pester/vscode-adapter) 10 | 11 | 🚧 THIS EXTENSION IS IN PREVIEW STATE, THERE ARE GOING TO BE BUGS 🚧 12 | 13 | This extension provides the ability to run [Pester](https://pester.dev/) tests utilizing the native 14 | [Testing functionality](https://code.visualstudio.com/updates/v1_59#_testing-apis) first introduced in Visual Studio Code 1.59 15 | 16 | ![Example](images/2021-08-07-08-06-26.png) 17 | 18 | ### Highlights 19 | 20 | 🔍 **Pester Test Browser** 21 | 🐞 **Debugging Support** 22 | 👩‍💻 **Native PowerShell Extension Integration** 23 | 👨‍👧‍👦 **Expands Test Cases** 24 | 25 | ### Extension Prerequisites 26 | 27 | - Pester 5.2.0 or later (sorry, no Pester 4 support) 28 | - PowerShell 7+ or Windows PowerShell 5.1 29 | 30 | ### Usage 31 | 32 | The extension will automatically discover all `.Tests.ps1` Pester files in your workspace, you can then run tests either 33 | from the Tests pane or from the green arrows that will appear adjacent to your tests. 34 | 35 | ### Installing the latest preview VSIX 36 | 37 | Preview VSIX extension packages are generated upon every commit to main and every pull request update. To install a beta build: 38 | 39 | 1. Click the green checkmark next to the latest commit 40 | 1. Click `Details` next to the `👷‍♂️ Build Visual Studio Code Extension` task 41 | 1. Click `Artifacts` in the upper right of the window 42 | 1. Download the latest artifact zip and unzip it, it should be named `vsix-{version}` 43 | 1. Open the resulting folder in vscode, right click the `.vsix` file, and choose `Install Extension VSIX` near the bottom. 44 | 1. Alternatively in vscode you can hit F1 and choose `Extensions: Install from VSIX` and browse for the vsix file. 45 | 46 | ### Configuration 47 | 48 | This extension will use the PowerShell Extension Pester verbosity settings for the output. 49 | 50 | ### Troubleshooting 51 | 52 | The Pester `Output` pane maintains a log of the activities that occur with the Pester extension. You can use `Set Log Level` in the command palette to increase the log level to debug or trace to get more information about what is going on. Include this information when submitting an issue along with reproduction steps. 53 | 54 | ### Known Issues 55 | 56 | - For test history purposes, a test is uniquely identified by its full path, e.g. Describe/Context/It. If you rename a test or move a test to another context/describe, it will be treated as a new test and test history will be reset 57 | - If you do not have any .tests.ps1 files in your directory upon startup, you will currently need to reload vscode for the extension to activate after you create the .tests.ps1 file. [This is is a known issue that is being tracked](https://github.com/pester/vscode-adapter/issues/122) 58 | -------------------------------------------------------------------------------- /Scripts/PesterInterface.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'PesterInterface' { 2 | BeforeAll { 3 | $SCRIPT:PesterInterface = Resolve-Path "$PSScriptRoot/PesterInterface.ps1" 4 | $SCRIPT:testDataPath = Resolve-Path "$PSScriptRoot/../sample" 5 | $SCRIPT:Mocks = Resolve-Path $testDataPath/Tests/Mocks 6 | } 7 | 8 | Context 'PesterInterface' { 9 | BeforeEach { 10 | Import-Module Pester -Force 11 | $SCRIPT:pipeOutPath = New-Item "Temp:\PesterInterfaceOutput-$(New-Guid).txt" -ItemType File -Force 12 | } 13 | AfterEach { 14 | Remove-Item $SCRIPT:pipeOutPath 15 | } 16 | It 'Basic.Tests Discovery' { 17 | $paths = "$testDataPath/Tests/Basic.Tests.ps1" 18 | & $PesterInterface -Path $paths -Discovery -PipeName $PipeOutPath -DryRun 6>$null 19 | Get-Content $PipeOutPath | ConvertFrom-Json | ForEach-Object label | Should -HaveCount 58 20 | } 21 | It 'Simple Test Run' { 22 | $paths = "$testDataPath/Tests/True.Tests.ps1" 23 | & $PesterInterface -Path $paths -PipeName $PipeOutPath -DryRun 6>$null 24 | Get-Content $PipeOutPath | ConvertFrom-Json | ForEach-Object label | Should -HaveCount 2 -Because 'One for test start and one for test result' 25 | } 26 | It 'Syntax Error' { 27 | $paths = "$testDataPath/Tests/ContextSyntaxError.Tests.ps1", 28 | "$testDataPath/Tests/DescribeSyntaxError.Tests.ps1" 29 | & $PesterInterface -Path $paths -Discovery -PipeName $PipeOutPath -DryRun 6>$null 30 | $testResult = Get-Content $PipeOutPath | ConvertFrom-Json 31 | $testResult.id | Should -HaveCount 2 32 | $testResult | Where-Object id -Match 'Describesyntaxerror' | ForEach-Object error | Should -Match 'Missing closing' 33 | $testResult | Where-Object id -Match 'ContextSyntaxError' | ForEach-Object error | Should -Match 'Missing expression' 34 | } 35 | It 'BeforeAll Error' { 36 | $paths = "$testDataPath/Tests/BeforeAllError.Tests.ps1" 37 | & $PesterInterface -Path $paths -PipeName $PipeOutPath -DryRun 6>$null 38 | $testResult = Get-Content $PipeOutPath | ConvertFrom-Json 39 | $testResult.id | Should -Match 'TESTDESCRIBE$' 40 | $testResult.error | Should -Match 'Fails in Describe Block' 41 | } 42 | } 43 | Context 'New-TestItemId' { 44 | BeforeAll { 45 | Import-Module $PSScriptRoot\PesterTestPlugin.psm1 -Force 46 | } 47 | AfterAll { 48 | Remove-Module PesterTestPlugin 49 | } 50 | BeforeEach { 51 | $SCRIPT:baseMock = [PSCustomObject]@{ 52 | PSTypeName = 'Test' 53 | Path = 'Describe', 'Context', 'It' 54 | ScriptBlock = @{ 55 | File = 'C:\Path\To\Pester\File' 56 | } 57 | Data = $null 58 | } 59 | } 60 | It 'basic path' { 61 | New-TestItemId -AsString $baseMock | 62 | Should -Be $($baseMock.ScriptBlock.File, ($baseMock.Path -join '>>') -join '>>') 63 | } 64 | It 'Array testcase' { 65 | $baseMock.Data = @('test') 66 | New-TestItemId -AsString $baseMock | 67 | Should -Be $($baseMock.ScriptBlock.File, ($baseMock.Path -join '>>'), '_=test' -join '>>') 68 | } 69 | It 'Hashtable testcase one key' { 70 | $baseMock.Data = @{Name = 'Pester' } 71 | New-TestItemId -AsString $baseMock | 72 | Should -Be $($baseMock.ScriptBlock.File, ($baseMock.Path -join '>>'), 'Name=Pester' -join '>>') 73 | } 74 | It 'Hashtable testcase multiple key' { 75 | $baseMock.Data = @{Name = 'Pester'; Data = 'Something' } 76 | New-TestItemId -AsString $baseMock | 77 | Should -Be $($baseMock.ScriptBlock.File, ($baseMock.Path -join '>>'), 'Data=Something>>Name=Pester' -join '>>') 78 | } 79 | It 'Works without file' { 80 | $baseMock.Scriptblock.File = $null 81 | $baseMock.Data = @{Name = 'Pester'; Data = 'Something' } 82 | New-TestItemId -AsString $baseMock | 83 | Should -Be $(($baseMock.Path -join '>>'), 'Data=Something>>Name=Pester' -join '>>') 84 | } 85 | It 'Works with Pester.Block' { 86 | $Block = Import-Clixml $Mocks/Block.clixml 87 | New-TestItemId $Block -AsString | Should -Be 'Describe Nested Foreach >>Kind=Animal>>Name=giraffe>>Symbol=🦒' 88 | } 89 | } 90 | 91 | Context 'Expand-TestCaseName' { 92 | BeforeAll { 93 | Import-Module $PSScriptRoot\PesterTestPlugin.psm1 -Force 94 | } 95 | AfterAll { 96 | Remove-Module PesterTestPlugin 97 | } 98 | BeforeEach { 99 | $SCRIPT:baseMock = New-MockObject -Type Pester.Test 100 | $BaseMock.Name = 'Pester' 101 | } 102 | It 'Fails with wrong type' { 103 | $fake = [PSCustomObject]@{Name = 'Pester'; PSTypeName = 'NotATest' } 104 | { Expand-TestCaseName -Test $fake } | Should -Throw '*is not a Pester Test or Pester Block*' 105 | } 106 | It 'Works with Array testcase' { 107 | $baseMock.Name = 'Array TestCase <_>' 108 | $baseMock.Data = @('pester') 109 | Expand-TestCaseName $baseMock | Should -Be 'Array TestCase pester' 110 | } 111 | It 'Works with Single Hashtable testcase' { 112 | $baseMock.Name = 'Array TestCase ' 113 | $baseMock.Data = @{Name = 'pester' } 114 | Expand-TestCaseName $baseMock | Should -Be 'Array TestCase pester' 115 | } 116 | It 'Works with Multiple Hashtable testcase' { 117 | $baseMock.Name = 'Array TestCase ' 118 | $baseMock.Data = @{Name = 'pester'; Data = 'aCoolTest' } 119 | Expand-TestCaseName $baseMock | Should -Be 'Array aCoolTest TestCase pester' 120 | } 121 | 122 | It 'Works with Pester.Block' { 123 | $Block = Import-Clixml $Mocks/Block.clixml 124 | Expand-TestCaseName $Block | Should -Be 'Describe Nested Foreach giraffe' 125 | } 126 | } 127 | 128 | Context 'Get-DurationString' { 129 | BeforeAll { 130 | Import-Module $PSScriptRoot\PesterTestPlugin.psm1 -Force 131 | } 132 | AfterAll { 133 | Remove-Module PesterTestPlugin 134 | } 135 | It 'Duration Format ' { 136 | $mockTestParameter = [PSCustomObject] @{ 137 | UserDuration = $UserDuration 138 | FrameworkDuration = $FrameworkDuration 139 | } 140 | $getDurationStringResult = Get-DurationString $mockTestParameter 141 | $getDurationStringResult | Should -Be $Expected 142 | } -TestCases @( 143 | @{ 144 | Name = 'Both' 145 | UserDuration = 10000 146 | FrameworkDuration = 20000 147 | Expected = '(1ms|2ms)' 148 | }, 149 | @{ 150 | Name = 'Null UserDuration' 151 | UserDuration = $null 152 | FrameworkDuration = 20000 153 | Expected = $null 154 | }, 155 | @{ 156 | Name = 'Null FrameworkDuration' 157 | UserDuration = 10000 158 | FrameworkDuration = $null 159 | Expected = $null 160 | } 161 | ) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Scripts/PesterInterface.ps1: -------------------------------------------------------------------------------- 1 | using namespace System.Collections 2 | using namespace System.Collections.Generic 3 | using namespace Pester 4 | 5 | [CmdletBinding(PositionalBinding = $false)] 6 | param( 7 | #Path(s) to search for tests. Paths can also contain line numbers (e.g. /path/to/file:25) 8 | [Parameter(ValueFromRemainingArguments)][String[]]$Path = $PWD, 9 | #Only return the test information, don't actually run them. Also returns minimal output 10 | [Switch]$Discovery, 11 | #Only load the functions but don't execute anything. Used for testing. 12 | [Parameter(DontShow)][Switch]$LoadFunctionsOnly, 13 | #If specified, emit the output objects as a flattened json to the specified named pipe handle. Used for IPC to the extension host. 14 | #If this value is the special value 'stdout' or undefined, then the output object is written to stdout. 15 | [String]$PipeName, 16 | #The verbosity to pass to the system 17 | [String]$Verbosity, 18 | #If specified, the shim will write to a temporary file at Pipename path and this script will output what would have been written to the stream. Useful for testing. 19 | [Switch]$DryRun, 20 | #An optional custom path to the Pester module. 21 | [String]$CustomModulePath, 22 | #Include ANSI characters in output 23 | [Switch]$IncludeAnsi, 24 | #Specify the path to a PesterConfiguration.psd1 file. The script will also look for a PesterConfiguration.psd1 in the current working directory. 25 | [String]$ConfigurationPath 26 | ) 27 | 28 | try { 29 | $modulePath = if ($CustomModulePath) { Resolve-Path $CustomModulePath -ErrorAction Stop } else { 'Pester' } 30 | Import-Module -Name $modulePath -MinimumVersion '5.2.0' -ErrorAction Stop 31 | } catch { 32 | if ($PSItem.FullyQualifiedErrorId -ne 'Modules_ModuleWithVersionNotFound,Microsoft.PowerShell.Commands.ImportModuleCommand') { throw } 33 | 34 | throw [NotSupportedException]'Pester 5.2.0 or greater is required to use the Pester Tests extension but was not found on your system. Please install the latest version of Pester from the PowerShell Gallery.' 35 | } 36 | 37 | if ($psversiontable.psversion -ge '7.2.0') { 38 | if ($IncludeAnsi) { 39 | $PSStyle.OutputRendering = 'ANSI' 40 | } else { 41 | $PSStyle.OutputRendering = 'PlainText' 42 | } 43 | } 44 | 45 | if ($PSVersionTable.PSEdition -eq 'Desktop') { 46 | #Defaults to inquire 47 | $DebugPreference = 'continue' 48 | } 49 | Write-Debug "Home: $env:HOME" 50 | Write-Debug "PSModulePath: $env:PSModulePath" 51 | Write-Debug "OutputRendering: $($PSStyle.OutputRendering)" 52 | 53 | filter Import-PrivateModule ([Parameter(ValueFromPipeline)][string]$Path) { 54 | <# 55 | .SYNOPSIS 56 | This function imports a module from a file into a private variable and does not expose it via Get-Module. 57 | .NOTES 58 | Thanks to @SeeminglyScience for the inspiration 59 | #> 60 | 61 | #We dont use namespaces here to keep things portable 62 | $absolutePath = Resolve-Path $Path -ErrorAction Stop 63 | [Management.Automation.Language.Token[]]$tokens = $null 64 | [Management.Automation.Language.ParseError[]]$errors = $null 65 | [Management.Automation.Language.ScriptBlockAst]$scriptBlockAST = [Management.Automation.Language.Parser]::ParseFile($absolutePath, [ref]$tokens, [ref]$errors) 66 | 67 | if ($errors) { 68 | $errors | ForEach-Object { Write-Error $_.Message } 69 | return 70 | } 71 | 72 | return [psmoduleinfo]::new($scriptBlockAst.GetScriptBlock()) 73 | } 74 | 75 | function Register-PesterPlugin ([hashtable]$PluginConfiguration) { 76 | <# 77 | .SYNOPSIS 78 | Utilizes a private Pester API to register the plugin. 79 | #> 80 | $Pester = (Get-Command Invoke-Pester -ErrorAction Stop).Module 81 | & $Pester { 82 | param($PluginConfiguration) 83 | if ($null -ne $SCRIPT:additionalPlugins -and $testAdapterPlugin.Name -in $SCRIPT:additionalPlugins.Name) { 84 | Write-Debug "PesterInterface: $($testAdapterPlugin.Name) is already registered. Skipping..." 85 | return 86 | } 87 | 88 | if ($null -eq $SCRIPT:additionalPlugins) { 89 | $SCRIPT:additionalPlugins = @() 90 | } 91 | 92 | $testAdapterPlugin = New-PluginObject @PluginConfiguration 93 | $SCRIPT:additionalPlugins += $testAdapterPlugin 94 | } $PluginConfiguration 95 | } 96 | 97 | function Unregister-PesterPlugin ([hashtable]$PluginConfiguration) { 98 | <# 99 | .SYNOPSIS 100 | Utilizes a private Pester API to unregister the plugin. 101 | #> 102 | $Pester = (Get-Command Invoke-Pester -ErrorAction Stop).Module 103 | & $Pester { 104 | param($PluginConfiguration) 105 | if (-not $SCRIPT:additionalPlugins) { 106 | Write-Debug 'PesterInterface: No plugins are registered. Skipping...' 107 | return 108 | } 109 | 110 | $plugin = $SCRIPT:additionalPlugins | Where-Object Name -EQ $PluginConfiguration.Name 111 | if (-not $plugin) { 112 | Write-Debug "PesterInterface: $($PluginConfiguration.Name) is not registered. Skipping..." 113 | return 114 | } 115 | 116 | $SCRIPT:additionalPlugins = $SCRIPT:additionalPlugins | Where-Object Name -NE $PluginConfiguration.Name 117 | } $PluginConfiguration 118 | } 119 | 120 | 121 | #endregion Functions 122 | 123 | #Main Function 124 | function Invoke-Main { 125 | $pluginModule = Import-PrivateModule $PSScriptRoot/PesterTestPlugin.psm1 126 | 127 | $configArgs = @{ 128 | Discovery = $Discovery 129 | PipeName = $PipeName 130 | DryRun = $DryRun 131 | } 132 | 133 | #This syntax may seem strange but it allows us to inject state into the plugin. 134 | $plugin = & $pluginModule { 135 | param($externalConfigArgs) New-PesterTestAdapterPluginConfiguration @externalConfigArgs 136 | } $configArgs 137 | 138 | try { 139 | Register-PesterPlugin $plugin 140 | 141 | # These should be unique which is why we use a hashset 142 | [HashSet[string]]$paths = @() 143 | [HashSet[string]]$lines = @() 144 | # Including both the path and the line speeds up the script by limiting the discovery surface 145 | # Specifying just the line will still scan all files 146 | $Path.foreach{ 147 | if ($PSItem -match '(?.+?):(?\d+)$') { 148 | [void]$paths.Add($matches['Path']) 149 | [void]$lines.Add($PSItem) 150 | } else { 151 | [void]$paths.Add($PSItem) 152 | } 153 | } 154 | 155 | [PesterConfiguration]$config = if ($PesterPreference) { 156 | Write-Debug "$PesterPreference Detected, using for base configuration" 157 | $pesterPreference 158 | } elseif ($ConfigurationPath) { 159 | Write-Debug "-ConfigurationPath $ConfigurationPath was specified, looking for configuration" 160 | $resolvedConfigPath = Resolve-Path $ConfigurationPath -ErrorAction Stop 161 | Write-Debug "Pester Configuration found at $ConfigurationPath, using as base configuration." 162 | Import-PowerShellDataFile $resolvedConfigPath -ErrorAction Stop 163 | } elseif (Test-Path './PesterConfiguration.psd1') { 164 | Write-Debug "PesterConfiguration.psd1 found in test root directory $cwd, using as base configuration." 165 | Import-PowerShellDataFile './PesterConfiguration.psd1' -ErrorAction Stop 166 | } else { 167 | New-PesterConfiguration 168 | } 169 | 170 | $config.Run.SkipRun = [bool]$Discovery 171 | $config.Run.PassThru = $true 172 | 173 | #If Verbosity is $null it will use PesterPreference 174 | if ($Discovery) { $config.Output.Verbosity = 'None' } 175 | elseif ($Verbosity) { $config.Output.Verbosity = $Verbosity } 176 | 177 | if ($paths.Count) { 178 | $config.Run.Path = [string[]]$paths #Cast to string array is required or it will error 179 | } 180 | if ($lines.Count) { 181 | $config.Filter.Line = [string[]]$lines #Cast to string array is required or it will error 182 | } 183 | Invoke-Pester -Configuration $config | Out-Null 184 | } catch { 185 | throw 186 | } finally { 187 | Unregister-PesterPlugin $plugin 188 | } 189 | } 190 | 191 | #Run Main function 192 | if (-not $LoadFunctionsOnly) { Invoke-Main } 193 | -------------------------------------------------------------------------------- /Scripts/PesterTestPlugin.psm1: -------------------------------------------------------------------------------- 1 | using namespace Pester 2 | using namespace System.Management.Automation 3 | using namespace System.Collections 4 | using namespace System.Collections.Generic 5 | using namespace System.IO 6 | using namespace System.IO.Pipes 7 | using namespace System.Text 8 | 9 | function New-PesterTestAdapterPluginConfiguration { 10 | param( 11 | [string]$PipeName, 12 | [switch]$Discovery, 13 | [switch]$DryRun 14 | ) 15 | 16 | $SCRIPT:PipeName = $PipeName 17 | $SCRIPT:Discovery = $Discovery 18 | $SCRIPT:DryRun = $DryRun 19 | 20 | @{ 21 | Name = 'PesterVSCodeTestAdapter' 22 | Start = { 23 | $SCRIPT:__TestAdapterKnownParents = [HashSet[Pester.Block]]::new() 24 | if ($DryRun) { 25 | Write-Host -ForegroundColor Magenta "Dryrun Detected. Writing to file $PipeName" 26 | } else { 27 | if (-not (!$pipeName -or $pipeName -eq 'stdout')) { 28 | Write-Host -ForegroundColor Green "Connecting to pipe $PipeName" 29 | $SCRIPT:__TestAdapterNamedPipeClient = [NamedPipeClientStream]::new($PipeName) 30 | $__TestAdapterNamedPipeClient.Connect(5000) 31 | $SCRIPT:__TestAdapterNamedPipeWriter = [StreamWriter]::new($__TestAdapterNamedPipeClient) 32 | } 33 | } 34 | } 35 | DiscoveryEnd = { 36 | param($Context) 37 | if (-not $Discovery) { continue } 38 | $discoveredTests = & (Get-Module Pester) { $Context.BlockContainers | View-Flat } 39 | [Array]$discoveredTests = & (Get-Module Pester) { $Context.BlockContainers | View-Flat } 40 | $failedBlocks = $Context.BlockContainers | Where-Object -Property ErrorRecord 41 | $discoveredTests += $failedBlocks 42 | $discoveredTests.foreach{ 43 | if ($PSItem -is [Pester.Test]) { 44 | [Pester.Block[]]$testSuites = Get-TestItemParents -Test $PSItem -KnownParents $SCRIPT:__TestAdapterKnownParents 45 | $testSuites.foreach{ 46 | Write-TestItem -TestDefinition $PSItem -PipeName $PipeName -DryRun:$DryRun 47 | } 48 | } 49 | Write-TestItem -TestDefinition $PSItem -PipeName $PipeName -DryRun:$DryRun 50 | } 51 | } 52 | EachTestSetup = { 53 | param($Context) 54 | Write-TestItem -TestDefinition $Context.Test -PipeName $PipeName -DryRun:$DryRun 55 | } 56 | EachTestTeardownEnd = { 57 | param($Context) 58 | if (-not $Context) { continue } 59 | Write-TestItem -TestDefinition $Context.Test -PipeName $PipeName -DryRun:$DryRun 60 | } 61 | OneTimeBlockTearDownEnd = { 62 | param($Context) 63 | if (-not $Context) { continue } 64 | [Pester.Block]$Block = $Context.Block 65 | # Report errors in the block itself. This should capture BeforeAll/AfterAll issues 66 | if ($Block.ErrorRecord) { 67 | Write-TestItem -TestDefinition $Block -PipeName $PipeName -DryRun:$DryRun 68 | } 69 | } 70 | End = { 71 | if (!$DryRun -and -not (!$pipeName -or $pipeName -eq 'stdout')) { 72 | $SCRIPT:__TestAdapterNamedPipeWriter.flush() 73 | $SCRIPT:__TestAdapterNamedPipeWriter.dispose() 74 | $SCRIPT:__TestAdapterNamedPipeClient.Close() 75 | } 76 | } 77 | } 78 | } 79 | 80 | filter Write-TestItem([Parameter(ValueFromPipeline)]$TestDefinition, [string]$PipeName, [switch]$DryRun) { 81 | $testItem = New-TestObject $TestDefinition 82 | [string]$jsonObject = ConvertTo-Json $testItem -Compress -Depth 1 83 | if (!$DryRun) { 84 | if (!$pipeName -or $pipeName -eq 'stdout') { 85 | [void][Console]::Out.WriteLineAsync($jsonObject) 86 | } else { 87 | $__TestAdapterNamedPipeWriter.WriteLine($jsonObject) 88 | } 89 | } else { 90 | $jsonObject >> $PipeName 91 | } 92 | } 93 | 94 | function Merge-TestData () { 95 | #Produce a unified test Data object from this object and its parents 96 | #Merge the local data and block data. Local data takes precedence. 97 | #Used in Test ID creation 98 | [CmdletBinding()] 99 | param( 100 | [Parameter(Mandatory, ValueFromPipeline)] 101 | [ValidateScript( { 102 | [bool]($_.PSTypeNames -match '(Pester\.)?(Test|Block)$') 103 | })]$Test 104 | ) 105 | #TODO: Nested Describe/Context Foreach? 106 | #TODO: Edge cases 107 | #Non-String Object 108 | #Non-String Hashtable 109 | #Other dictionaries 110 | #Nested Hashtables 111 | #Fancy TestCases, maybe just iterate them as TestCaseN or exclude 112 | 113 | 114 | #If data is not iDictionary array, we will store it as _ to standardize this is a bit 115 | $Data = [SortedDictionary[string, object]]::new() 116 | 117 | #Block and parent are interchangeable 118 | if ($Test -is [Pester.Test]) { 119 | Add-Member -InputObject $Test -NotePropertyName 'Parent' -NotePropertyValue $Test.Block -Force 120 | } 121 | 122 | #This will merge the block data, with the lowest level data taking precedence 123 | #TODO: Use a stack to iterate this 124 | $DataSources = ($Test.Parent.Parent.Data, $Test.Parent.Data, $Test.Data).where{ $PSItem } 125 | foreach ($DataItem in $DataSources) { 126 | if ($DataItem) { 127 | if ($DataItem -is [IDictionary]) { 128 | $DataItem.GetEnumerator().foreach{ 129 | $Data.$($PSItem.Name) = $PSItem.Value 130 | } 131 | } else { 132 | #Save to the "_" key if it was an array input since that's what Pester uses for substitution 133 | $Data._ = $DataItem 134 | } 135 | } 136 | } 137 | return $Data 138 | } 139 | 140 | function Expand-TestCaseName { 141 | [CmdletBinding()] 142 | param( 143 | [Parameter(Mandatory, ValueFromPipeline)] 144 | $Test 145 | ) 146 | begin { 147 | Test-IsPesterObject $Test 148 | } 149 | process { 150 | [String]$Name = $Test.Name.ToString() 151 | 152 | $Data = Merge-TestData $Test 153 | 154 | # Array value was stored as _ by Merge-TestData 155 | $Data.GetEnumerator().ForEach{ 156 | $Name = $Name -replace ('<{0}>' -f $PSItem.Key), $PSItem.Value 157 | } 158 | 159 | return $Name 160 | } 161 | } 162 | 163 | function New-TestItemId { 164 | <# 165 | .SYNOPSIS 166 | Create a string that uniquely identifies a test or test suite 167 | .NOTES 168 | Can be replaced with expandedpath if https://github.com/pester/Pester/issues/2005 is fixed 169 | #> 170 | [CmdletBinding()] 171 | param( 172 | [Parameter(Mandatory, ValueFromPipeline)] 173 | [ValidateScript( { 174 | $null -ne ($_.PSTypeNames -match '(Pester\.)?(Block|Test)$') 175 | })]$Test, 176 | $TestIdDelimiter = '>>', 177 | [Parameter(DontShow)][Switch]$AsString, 178 | [Parameter(DontShow)][Switch]$AsHash 179 | ) 180 | process { 181 | 182 | if ($Test.Path -match $TestIdDelimiter) { 183 | throw [NotSupportedException]"The delimiter $TestIdDelimiter is not supported in test names with this adapter. Please remove all pipes from test/context/describe names" 184 | } 185 | 186 | $Data = Merge-TestData $Test 187 | 188 | #Add a suffix of the testcase/foreach info that should uniquely identify the etst 189 | #TODO: Maybe use a hash of the serialized object if it is not natively a string? 190 | #TODO: Or maybe just hash the whole thing. The ID would be somewhat useless for troubleshooting 191 | $DataItems = $Data.GetEnumerator() | Sort-Object Key | ForEach-Object { 192 | [String]([String]$PSItem.Key + '=' + [String]$PSItem.Value) 193 | } 194 | 195 | # If this is a root container, just return the file path, since root containers can only be files (for now) 196 | if ($Test -is [Pester.Block] -and $Test.IsRoot) { 197 | [string]$path = $Test.BlockContainer.Item 198 | if ($IsWindows -or $PSEdition -eq 'Desktop') { 199 | return $path.ToUpper() 200 | } else { 201 | return $path 202 | } 203 | } 204 | 205 | [String]$TestID = @( 206 | # Javascript uses lowercase drive letters 207 | $Test.ScriptBlock.File 208 | # Can NOT Use expandedPath here, because when test runs it extrapolates 209 | $Test.Path 210 | $DataItems 211 | ).Where{ $PSItem } -join '>>' 212 | 213 | if (-not $TestID) { throw 'A test ID was not generated. This is a bug.' } 214 | 215 | if ($AsHash) { 216 | #Clever: https://www.reddit.com/r/PowerShell/comments/dr3taf/does_powershell_have_a_native_command_to_hash_a/ 217 | #TODO: This should probably be a helper function 218 | Write-Debug "Non-Hashed Test ID for $($Test.ExpandedPath): $TestID" 219 | return (Get-FileHash -InputStream ( 220 | [MemoryStream]::new( 221 | [Text.Encoding]::UTF8.GetBytes($TestID) 222 | ) 223 | ) -Algorithm SHA256).hash 224 | } 225 | 226 | # -AsString is now the default, keeping for other existing references 227 | 228 | # ToUpper is used to normalize windows paths to all uppercase 229 | if ($IsWindows -or $PSEdition -eq 'Desktop') { 230 | $TestID = $TestID.ToUpper() 231 | } 232 | return $TestID 233 | } 234 | } 235 | 236 | function Test-IsPesterObject { 237 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 238 | 'PSUseDeclaredVarsMoreThanAssignments', 239 | '', 240 | Justification = 'Scriptanalyzer bug: Reference is not tracked through callback', 241 | Scope = 'Function' 242 | )] 243 | param($Test) 244 | 245 | #We don't use types here because they might not be loaded yet 246 | $AllowedObjectTypes = [String[]]@( 247 | 'Test' 248 | 'Block' 249 | 'Pester.Test' 250 | 'Pester.Block' 251 | 'Deserialized.Pester.Test' 252 | 'Deserialized.Pester.Block' 253 | ) 254 | $AllowedObjectTypes.foreach{ 255 | if ($PSItem -eq $Test.PSTypeNames[0]) { 256 | $MatchesType = $true 257 | return 258 | } 259 | } 260 | if (-not $MatchesType) { throw 'Provided object is not a Pester Test or Pester Block' } 261 | } 262 | 263 | function Get-DurationString($Test) { 264 | if (-not ($Test.UserDuration -and $Test.FrameworkDuration)) { return } 265 | $p = Get-Module Pester 266 | if ($p.Count -ge 2) { 267 | throw 'Multiple Pester modules found. Make sure to only have one Pester module imported in the session.' 268 | } 269 | & ($p) { 270 | $Test = $args[0] 271 | '({0}|{1})' -f (Get-HumanTime $Test.UserDuration), (Get-HumanTime $Test.FrameworkDuration) 272 | } $Test 273 | } 274 | 275 | function New-TestObject ($Test) { 276 | Test-IsPesterObject $Test 277 | 278 | #HACK: Block and Parent are equivalent so this simplifies further code 279 | if ($Test -is [Pester.Test]) { 280 | Add-Member -InputObject $Test -NotePropertyName 'Parent' -NotePropertyValue $Test.Block -Force 281 | } 282 | 283 | if (-not $Test.Parent -and ($Test -is [Pester.Test])) { 284 | throw "Item $($Test.Name) is a test but doesn't have an ancestor. This is a bug." 285 | } 286 | 287 | [String]$Parent = if ($Test.IsRoot) { 288 | $null 289 | } else { 290 | New-TestItemId $Test.Parent 291 | } 292 | 293 | if ($Test.ErrorRecord) { 294 | if ($Test -is [Pester.Block]) { 295 | [String]$DiscoveryError = $Test.ErrorRecord 296 | # Better formatting of parsing errors 297 | if ($Test.ErrorRecord.Exception -is [ParseException]) { 298 | $FirstParseError = $Test.ErrorRecord.Exception.Errors[0] 299 | $firstParseMessage = "Line $($FirstParseError.Extent.StartScriptPosition.LineNumber): $($FirstParseError.Message)" 300 | $DiscoveryError = $firstParseMessage + ([Environment]::NewLine * 2) + $Test.ErrorRecord 301 | } 302 | } else { 303 | #TODO: Better handling once pester adds support 304 | #Reference: https://github.com/pester/Pester/issues/1993 305 | $Message = [string]$Test.ErrorRecord 306 | if ([string]$Test.ErrorRecord -match 'Expected (?.+?), but (got )?(?.+?)\.$') { 307 | $Expected = $matches['Expected'] 308 | $Actual = $matches['Actual'] 309 | } 310 | } 311 | } 312 | 313 | # TypeScript does not validate these data types, so numbers must be expressly stated so they don't get converted to strings 314 | [PSCustomObject]@{ 315 | type = $Test.ItemType 316 | id = New-TestItemId $Test 317 | error = $DiscoveryError 318 | file = $Test.ScriptBlock.File 319 | startLine = [int]($Test.StartLine - 1) #Lines are zero-based in vscode 320 | endLine = [int]($Test.ScriptBlock.StartPosition.EndLine - 1) #Lines are zero-based in vscode 321 | label = Expand-TestCaseName $Test 322 | result = $(if (-not $Discovery) { $Test | Resolve-TestResult }) 323 | duration = $Test.UserDuration.TotalMilliseconds #I don't think anyone is doing sub-millisecond code performance testing in PowerShell :) 324 | durationDetail = Get-DurationString $Test 325 | message = $Message 326 | expected = $Expected 327 | actual = $Actual 328 | targetFile = $Test.ErrorRecord.TargetObject.File 329 | targetLine = [int]$Test.ErrorRecord.TargetObject.Line - 1 330 | parent = $Parent 331 | tags = $Test.Tag.Where{ $PSItem } 332 | scriptBlock = if ($Test -is [Pester.Test]) { $Test.Block.ScriptBlock.ToString().Trim() } 333 | #TODO: Severity. Failed = Error Skipped = Warning 334 | } 335 | } 336 | 337 | filter Resolve-TestResult ([Parameter(ValueFromPipeline)]$TestResult) { 338 | #This part borrowed from https://github.dev/pester/Pester/blob/7ca9c814cf32334303f7c506beaa6b1541554973/src/Pester.RSpec.ps1#L107-L122 because with the new plugin system it runs *after* our plugin unfortunately 339 | switch ($true) { 340 | ($TestResult.Duration -eq 0 -and $TestResult.ShouldRun -eq $true) { return 'Running' } 341 | ($TestResult.Skipped) { return 'Skipped' } 342 | ($TestResult.Passed) { return 'Passed' } 343 | (-not $discoveryOnly -and $TestResult.ShouldRun -and (-not $TestResult.Executed -or -not $TestResult.Passed)) { return 'Failed' } 344 | ($discoveryOnly -and 0 -lt $TestResult.ErrorRecord.Count) { return 'Running' } 345 | default { return 'NotRun' } 346 | } 347 | } 348 | 349 | function Get-TestItemParents { 350 | <# 351 | .SYNOPSIS 352 | Returns any parents not already known, top-down first, so that a hierarchy can be created in a streaming manner 353 | #> 354 | param ( 355 | #Test to fetch parents of. For maximum efficiency this should be done one test at a time and then stack processed 356 | [Parameter(Mandatory, ValueFromPipeline)][Pester.Test[]]$Test, 357 | [HashSet[Pester.Block]]$KnownParents = [HashSet[Pester.Block]]::new() 358 | ) 359 | 360 | begin { 361 | [Stack[Pester.Block]]$NewParents = [Stack[Pester.Block]]::new() 362 | } 363 | process { 364 | # Output all parents that we don't know yet (distinct parents), in order from the top most 365 | # to the child most. 366 | foreach ($TestItem in $Test) { 367 | $NewParents.Clear() 368 | $parent = $TestItem.Block 369 | 370 | while ($null -ne $parent -and -not $parent.IsRoot) { 371 | if (-not $KnownParents.Add($parent)) { 372 | # We know this parent, so we must know all of its parents as well. 373 | # We don't need to go further. 374 | break 375 | } 376 | 377 | $NewParents.Push($parent) 378 | $parent = $parent.Parent 379 | } 380 | 381 | # Output the unknown parent objects from the top most, to the one closest to our test. 382 | foreach ($p in $NewParents) { $p } 383 | } 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /Scripts/powershellRunner.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'PowerShellRunner Types' { 2 | It '' { 3 | function Invoke-Runner ([ScriptBlock]$ScriptBlock) { 4 | ". C:\Users\JGrote\Projects\vscode-adapter\Scripts\powershellRunner.ps1 {$ScriptBlock}" | pwsh -noprofile -noni -c - 2>$null 5 | } 6 | 7 | $runResult = Invoke-Runner $TestValue 8 | #The last message is a script finished message, not relevant to this test 9 | $result = ($runResult | ConvertFrom-Json | Select-Object -SkipLast 1) 10 | $result.__PSStream | Should -Be $Stream 11 | $result.__PSType | Should -Be $Type 12 | if ($result.message -is [datetime]) { 13 | # Date compare after rehydration doesn't work for some reason 14 | $result = $result.ToUniversalTime().ToString('s') 15 | } 16 | $result | Should -Be $ExpectedResult 17 | 18 | } -TestCases @( 19 | @{ 20 | Name = 'int' 21 | TestValue = { 1 } 22 | ExpectedResult = 1 23 | } 24 | @{ 25 | Name = 'double' 26 | TestValue = { -1.5 } 27 | ExpectedResult = -1.5 28 | } 29 | @{ 30 | Name = 'string' 31 | TestValue = { 'pester' } 32 | ExpectedResult = 'pester' 33 | } 34 | @{ 35 | Name = 'datetime' 36 | TestValue = { Get-Date -Date '1/1/2025 3:22PM' } 37 | ExpectedResult = Get-Date '2025-01-01T15:22:00.0000000' 38 | } 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /Scripts/powershellRunner.ps1: -------------------------------------------------------------------------------- 1 | #requires -version 5.1 2 | using namespace System.Collections 3 | using namespace System.Collections.Generic 4 | using namespace System.Diagnostics 5 | using namespace System.Management.Automation 6 | using namespace System.Management.Automation.Runspaces 7 | param( 8 | #The scriptblock to run 9 | [ScriptBlock]$ScriptBlock, 10 | #We typically emit simple messages for verbose/warning/debug streams. Specify this to get a full record object. 11 | [Switch]$FullMessages, 12 | #How many levels of an object to return, anything beyond this depth will attempt to be stringified instead. This can have serious performance implications. 13 | [int]$Depth = 1, 14 | #How often to check for script completion, in seconds. You should only need to maybe increase this if there is an averse performance impact. 15 | [double]$sleepInterval = 0.05, 16 | #Safety timeout in seconds. This avoids infinite loops. Increase for very long running scripts. 17 | [int]$timeout = 3600, 18 | #Include ANSI characters in the output. This is only supported on 7.2 or above. 19 | [Switch]$IncludeAnsi, 20 | #Do not reuse a previously found session, useful for pester tests or environments that leave a dirty state. 21 | [Switch]$NoSessionReuse, 22 | #Specify an invocation ID to track individual invocations. This will be supplied in the finish message. 23 | [string]$Id = (New-Guid) 24 | ) 25 | Set-StrictMode -Version 3 26 | $ErrorActionPreference = 'Stop' 27 | [Console]::OutputEncoding = [Text.Encoding]::UTF8 28 | 29 | #This is required to ensure dates get ISO8601 formatted during json serialization 30 | Get-TypeData System.DateTime | Remove-TypeData 31 | 32 | if ($psversiontable.psversion -ge '7.2.0') { 33 | if ($IncludeAnsi) { 34 | $PSStyle.OutputRendering = 'ANSI' 35 | } else { 36 | $PSStyle.OutputRendering = 'PlainText' 37 | } 38 | } 39 | # $ScriptBlock = [ScriptBlock]::Create($script) 40 | [PowerShell]$psinstance = if (!$NoSessionReuse -and (Test-Path Variable:__NODEPSINSTANCE)) { 41 | $GLOBAL:__NODEPSINSTANCE 42 | } else { 43 | [powershell]::Create() 44 | } 45 | 46 | [void]$psInstance.AddScript($ScriptBlock) 47 | $psInstance.Commands[0].Commands[0].MergeMyResults([PipeLineResultTypes]::All, [PipeLineResultTypes]::Output) 48 | $psInput = [PSDataCollection[Object]]::new() 49 | $psOutput = [PSDataCollection[Object]]::new() 50 | 51 | function Test-IsPrimitive ($InputObject) { 52 | ($InputObject.gettype().IsPrimitive -or $InputObject -is [string] -or $InputObject -is [datetime]) 53 | } 54 | 55 | function Add-TypeIdentifier ($InputObject) { 56 | [string]$typeName = if ($InputObject -is [PSCustomObject]) { 57 | $InputObject.pstypenames[0] 58 | } else { 59 | $InputObject.GetType().FullName 60 | } 61 | Add-Member -InputObject $InputObject -NotePropertyName '__PSType' -NotePropertyValue $typeName 62 | } 63 | 64 | function Add-StreamIdentifier ($inputObject) { 65 | $streamObjectTypes = @( 66 | [DebugRecord], 67 | [VerboseRecord], 68 | [WarningRecord], 69 | [ErrorRecord], 70 | [InformationRecord], 71 | [ProgressRecord] 72 | ) 73 | if ($inputObject.gettype() -in $StreamObjectTypes) { 74 | # Generate 'simple' records for these types by converting them to strings 75 | # The record types know how to adjust their messaging accordingly 76 | $streamName = $inputObject.getType().Name -replace 'Record$', '' 77 | if (!$FullMessages) { 78 | # The pscustomobject is required for PS 7.2+ 79 | $InputObject = [PSCustomObject]@{ 80 | value = [String]$inputObject 81 | } 82 | } 83 | 84 | Add-Member -InputObject $inputObject -NotePropertyName '__PSStream' -NotePropertyValue $streamName -PassThru 85 | } else { 86 | $inputObject 87 | } 88 | } 89 | 90 | function Out-JsonToStdOut { 91 | [CmdletBinding()] 92 | param( 93 | [Parameter(ValueFromPipeline)]$InputObject, 94 | [int]$Depth 95 | ) 96 | process { 97 | if (!(Test-IsPrimitive $InputObject)) { 98 | Add-TypeIdentifier $inputObject 99 | } 100 | $finalObject = Add-StreamIdentifier $inputObject 101 | $json = ConvertTo-Json -InputObject $finalObject -Compress -Depth $Depth -WarningAction SilentlyContinue 102 | [void][Console]::Out.WriteLineAsync($json) 103 | } 104 | } 105 | 106 | # InvokeAsync doesn't exist in 5.1 107 | $psStatus = $psInstance.BeginInvoke($psInput, $psOutput) 108 | [Console]::OutputEncoding = [Text.Encoding]::UTF8 109 | 110 | # $psOutput while enumerating will block the pipeline while waiting for a new item, and will release when script is finished. 111 | $psOutput | Out-JsonToStdOut -Depth $Depth 112 | try { 113 | $psInstance.EndInvoke($psStatus) 114 | } catch { 115 | [String]$ErrorAsJson = ConvertTo-Json -Compress -WarningAction SilentlyContinue -InputObject $PSItem.exception.innerexception.errorrecord 116 | [void][Console]::Error.WriteLine($ErrorAsJson) 117 | } finally { 118 | $psInstance.Commands.Clear() 119 | 120 | # Store the runspace where it can be reused for performance 121 | $GLOBAL:__NODEPSINSTANCE = $psInstance 122 | 123 | $finishedMessage = [PSCustomObject]@{ 124 | __PSINVOCATIONID = $Id 125 | finished = $true 126 | } | ConvertTo-Json -Compress -Depth 1 127 | [void][Console]::Out.WriteLine($finishedMessage) 128 | } 129 | -------------------------------------------------------------------------------- /images/2021-08-07-08-06-26.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pester/vscode-adapter/c94e38efbdcfd39f8c0fcb50732ca77509c279e9/images/2021-08-07-08-06-26.png -------------------------------------------------------------------------------- /images/pesterlogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pester/vscode-adapter/c94e38efbdcfd39f8c0fcb50732ca77509c279e9/images/pesterlogo.png -------------------------------------------------------------------------------- /images/pesterlogo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/social-preview.pdn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pester/vscode-adapter/c94e38efbdcfd39f8c0fcb50732ca77509c279e9/images/social-preview.pdn -------------------------------------------------------------------------------- /images/social-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pester/vscode-adapter/c94e38efbdcfd39f8c0fcb50732ca77509c279e9/images/social-preview.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pester-test", 3 | "displayName": "Pester Tests", 4 | "description": "Enables you to run PowerShell Pester tests using the VSCode testing feature", 5 | "version": "0.0.0-source", 6 | "publisher": "pspester", 7 | "license": "MIT", 8 | "preview": true, 9 | "repository": "https://github.com/pester/vscode-adapter", 10 | "engines": { 11 | "vscode": "^1.77.0" 12 | }, 13 | "packageManager": "pnpm@8.3.1", 14 | "contributes": { 15 | "configuration": { 16 | "title": "Pester", 17 | "properties": { 18 | "pester.testFilePath": { 19 | "type": "array", 20 | "items": { 21 | "type": "string" 22 | }, 23 | "default": [ 24 | "**/*.[tT]ests.[pP][sS]1" 25 | ], 26 | "markdownDescription": "Specify an array of [Glob Patterns](https://code.visualstudio.com/docs/editor/codebasics#_advanced-search-options) of Pester test files for the adapter to scan, relative to your workspace. This is useful if you want to limit what test files or scanned, or you want to use a different test format than `.tests.ps1`. You must specify files, so if you want to include an entire folder, add a wildcard, for instance `Tests\\*.Tests.ps1`. **This is case sensitive!** Here is a case insensitive example: `[tT]ests\\*.[tT]ests.[pP][sS]1`" 27 | }, 28 | "pester.hideSkippedBecauseMessages": { 29 | "type": "boolean", 30 | "markdownDescription": "If a skipped test is inconclusive or `Set-ItResult` had the `-Because` parameter specified, those are surfaced as test errors for easy viewing. Enable this setting if you prefer these to be displayed simply as skipped. **NOTE:** If you do this then you cannot see any BECAUSE messages without looking directly at the Pester output." 31 | }, 32 | "pester.autoDebugOnSave": { 33 | "type": "boolean", 34 | "default": false, 35 | "markdownDescription": "When enabled, continuous run tests will be debugged when changed in the PowerShell extension." 36 | }, 37 | "pester.suppressCodeLensNotice": { 38 | "type": "boolean", 39 | "markdownDescription": "It is recommended to disable the PowerShell Pester CodeLens when using this extension. Check this box to suppress this dialog. Choosing 'No' in the dialog box will also enable this at a user level" 40 | }, 41 | "pester.runTestsInNewProcess": { 42 | "type": "boolean", 43 | "markdownDescription": "This will cause all Pester Test invocations to occur each time in a new process, rather than reuse a runspace. This is useful if you are testing Powershell Classes or Assemblies that cannot be unloaded or reloaded, but comes at a significant performance cost. This currently has no effect on debugging tests." 44 | }, 45 | "pester.pesterModulePath": { 46 | "type": "string", 47 | "markdownDescription": "This allows to specify a custom path to the Pester module. A relative path under the current project is allowed. If this is not set then Pester module is expected to be found in one of the paths specified by `$env:PSModulePath`." 48 | }, 49 | "pester.workingDirectory": { 50 | "type": "string", 51 | "markdownDescription": "Specifies the working directory of the dedicated Pester Instance. While $PSScriptRoot is recommended when using relative paths in Pester Tests, the runner will also default to the path of the current first workspace folder. Specify this if you want a different working directory." 52 | }, 53 | "pester.autoRunOnSave": { 54 | "type": "boolean", 55 | "default": false, 56 | "deprecationMessage": "DEPRECATED: This is now handled by the VSCode native continuous run interface in the test handler.", 57 | "markdownDescription": "Automatically runs Pester test files upon changes being detected on save. Uncheck this box to disable this behavior." 58 | }, 59 | "pester.configurationPath": { 60 | "type": "string", 61 | "markdownDescription": "Specifies the path to a custom Pester configuration in `.psd1` format. This is useful if you want to use a custom configuration file, or if you want to use a configuration file that is not named `PesterConfiguration.psd1`. If this is not set then the runner will look for a file named `PesterConfiguration.psd1` at the root of the workspace folder. This is best specified as a workspace setting and not a user setting. You may also provide a configuration via a `$PesterPrefer ence` variable in your running session, this will apply for debug runs only." 62 | }, 63 | "pester.testChangeTimeout": { 64 | "type": "integer", 65 | "default": 100, 66 | "markdownDescription": "Specifies the timeout in milliseconds that the test adapter waits for changes to be detected before running the test. You may need to increase this value if you are on a slower computer and are getting duplicate runs after making changes or starting up the test adapter." 67 | } 68 | } 69 | } 70 | }, 71 | "keywords": [ 72 | "Pester", 73 | "PowerShell", 74 | "Test", 75 | "Debug" 76 | ], 77 | "categories": [ 78 | "Debuggers", 79 | "Programming Languages", 80 | "Other", 81 | "Testing" 82 | ], 83 | "activationEvents": [ 84 | "workspaceContains:**/*.[tT]ests.[pP][sS]1", 85 | "onStartupFinished" 86 | ], 87 | "main": "./dist/extension.js", 88 | "devDependencies": { 89 | "@types/chai": "^4.3.5", 90 | "@types/debounce-promise": "^3.1.9", 91 | "@types/glob": "^8.1.0", 92 | "@types/json-parse-safe": "^2.0.3", 93 | "@types/mocha": "^10.0.6", 94 | "@types/node": "^20.10.6", 95 | "@types/readline-transform": "^1.0.4", 96 | "@types/vscode": "^1.77.0", 97 | "@typescript-eslint/eslint-plugin": "^6.16.0", 98 | "@typescript-eslint/parser": "^6.16.0", 99 | "@vscode/dts": "^0.4.0", 100 | "@vscode/test-cli": "^0.0.4", 101 | "@vscode/test-electron": "^2.3.8", 102 | "@vscode/vsce": "^2.22.0", 103 | "chai": "^5.0.0", 104 | "debounce-promise": "^3.1.2", 105 | "esbuild": "^0.19.11", 106 | "esbuild-register": "^3.5.0", 107 | "eslint": "^8.56.0", 108 | "eslint-config-prettier": "^9.1.0", 109 | "eslint-config-standard-with-typescript": "^43.0.0", 110 | "eslint-plugin-import": "^2.29.1", 111 | "eslint-plugin-n": "^16.6.0", 112 | "eslint-plugin-promise": "^6.1.1", 113 | "glob": "^10.3.3", 114 | "json-parse-safe": "^2.0.0", 115 | "lookpath": "^1.2.2", 116 | "mocha": "^10.2.0", 117 | "path": "^0.12.7", 118 | "prettier": "^3.1.1", 119 | "prettier-eslint": "^16.2.0", 120 | "readline-transform": "^1.0.0", 121 | "sinon": "^17.0.1", 122 | "tslog": "^4.9.2", 123 | "typescript": "^5.3.3", 124 | "utility-types": "^3.10.0" 125 | }, 126 | "extensionDependencies": [ 127 | "ms-vscode.powershell" 128 | ], 129 | "icon": "images/pesterlogo.png", 130 | "badges": [ 131 | { 132 | "url": "https://img.shields.io/github/stars/pester/vscode-adapter?style=social", 133 | "description": "Stars", 134 | "href": "https://github.com/pester/vscode-adapter" 135 | }, 136 | { 137 | "url": "https://img.shields.io/github/workflow/status/pester/vscode-adapter/%F0%9F%91%B7%E2%80%8D%E2%99%82%EF%B8%8F%20Build%20Visual%20Studio%20Code%20Extension", 138 | "description": "Build", 139 | "href": "https://github.com/pester/vscode-adapter/actions/workflows/ci.yml" 140 | } 141 | ], 142 | "scripts": { 143 | "esbuild-base": "pnpm esbuild --minify --bundle --platform=node --target=node18.15 --format=cjs --sourcemap --sources-content=false --external:mocha --external:vscode", 144 | "package": "vsce package --no-update-package-json --no-git-tag-version --no-dependencies", 145 | "publish": "pnpm run publishStable --pre-release --no-git-tag-version", 146 | "publishStable": "vsce publish --no-update-package-json --no-dependencies", 147 | "build": "pnpm run esbuild-base src/extension.ts --outdir=dist", 148 | "build-watch": "pnpm run build --watch", 149 | "build-test-vscode": "pnpm run esbuild-base src/**/*.vscode.test.ts test/**/*.vscode.test.ts --outdir=dist/test", 150 | "build-test-vscode-watch": "pnpm run build-test-vscode --watch", 151 | "test-mocha": "mocha", 152 | "test-mocha-vscode": "pnpm vscode-test", 153 | "test": "pnpm test-mocha && pnpm test-mocha-vscode", 154 | "test-watch": "pnpm test-mocha --watch", 155 | "lint-eslint": "eslint . --ext .ts", 156 | "lint-prettier": "prettier --check .", 157 | "lint-tsc": "tsc --noemit", 158 | "lint": "pnpm lint-eslint || pnpm lint-prettier || pnpm lint-tsc", 159 | "download-api": "vscode-dts main", 160 | "postdownload-api": "vscode-dts main", 161 | "vscode:prepublish": "pnpm run build" 162 | }, 163 | "dependencies": { 164 | "@ctiterm/strip-ansi": "^1.0.0" 165 | }, 166 | "pnpm": { 167 | "patchedDependencies": { 168 | "@vscode/test-cli@0.0.4": "patches/@vscode__test-cli@0.0.4.patch" 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /patches/@vscode__test-cli@0.0.4.patch: -------------------------------------------------------------------------------- 1 | diff --git a/out/bin.mjs b/out/bin.mjs 2 | index 5282c55bd62d0b77c17f77a173ff848286c0c2f8..857e6d9ca1b6275185c11ab7ebadb1646b5a5afe 100644 3 | --- a/out/bin.mjs 4 | +++ b/out/bin.mjs 5 | @@ -11,6 +11,9 @@ import { dirname, isAbsolute, join, resolve } from 'path'; 6 | import supportsColor from 'supports-color'; 7 | import { fileURLToPath, pathToFileURL } from 'url'; 8 | import yargs from 'yargs'; 9 | +import { createRequire } from 'node:module'; 10 | + 11 | +const require = createRequire(import.meta.url); 12 | const rulesAndBehavior = 'Mocha: Rules & Behavior'; 13 | const reportingAndOutput = 'Mocha: Reporting & Output'; 14 | const fileHandling = 'Mocha: File Handling'; 15 | -------------------------------------------------------------------------------- /sample/Tests.2/Empty.Tests.ps1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pester/vscode-adapter/c94e38efbdcfd39f8c0fcb50732ca77509c279e9/sample/Tests.2/Empty.Tests.ps1 -------------------------------------------------------------------------------- /sample/Tests.2/True.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'SimpleTest' { 2 | It 'True should be true' { 3 | $true | Should -Be $true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /sample/Tests.3/Test3Unique.Tests.ps1: -------------------------------------------------------------------------------- 1 | Context 'Test3 Unique Context' { 2 | It 'Test3UniqueIt' { 3 | $true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /sample/Tests.3/True.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'SimpleTest' { 2 | It 'True should be true' { 3 | $true | Should -Be $true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /sample/Tests.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "Tests" 5 | }, 6 | { 7 | "path": "Tests.2" 8 | }, 9 | { 10 | "path": "Tests.3" 11 | } 12 | ], 13 | "settings": { 14 | "git.openRepositoryInParentFolders": "never", 15 | "extensions.ignoreRecommendations": true, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sample/Tests/Basic.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'Basic' { 2 | 3 | Context 'Succeeds' { 4 | It 'True' { 5 | $true 6 | } 7 | It 'False' { $false } 8 | It 'ShouldBeTrue' { $true | Should -Be $true } 9 | } 10 | Context 'Fails' { 11 | It 'Throws' { throw 'Kaboom' } 12 | It 'ShouldThrowButDoesNot' { { $true } | Should -Throw } 13 | It 'ShouldBeTrueButIsFalse' { $false | Should -Be $true } 14 | It 'ShouldBeTrueButIsFalseBecause' { $false | Should -BeTrue -Because 'True is True' } 15 | It 'ShouldBeGreaterThanButIsLessThan' { 1 | Should -BeGreaterThan 2 } 16 | } 17 | It 'Describe-Level Succeeds' { $true | Should -Be $true } 18 | It 'Describe-Level Fails' { $true | Should -Be $false } 19 | It 'Skipped' { 20 | Set-ItResult -Skipped 21 | } 22 | It 'Skipped Because' { 23 | Set-ItResult -Skipped -Because 'It was skipped' 24 | } 25 | It 'Inconclusive' { 26 | Set-ItResult -Inconclusive 27 | } 28 | It 'Inconclusive Because' { 29 | Set-ItResult -Inconclusive -Because 'It was Inconclusive' 30 | } 31 | } 32 | 33 | Describe 'TestCases' { 34 | It 'TestCase Array <_>' { 35 | $_ | Should -Not -BeNullOrEmpty 36 | } -TestCases @( 37 | 1 38 | 2 39 | 'red' 40 | 'blue' 41 | ) 42 | It 'TestCase HashTable ' { 43 | $_ | Should -Not -BeNullOrEmpty 44 | } -TestCases @( 45 | @{Name = 1 } 46 | @{Name = 2 } 47 | @{Name = 'red' } 48 | @{Name = 'blue' } 49 | ) 50 | } 51 | 52 | Describe 'Describe Nested Foreach ' -ForEach @( 53 | @{ Name = 'cactus'; Symbol = '🌵'; Kind = 'Plant' } 54 | @{ Name = 'giraffe'; Symbol = '🦒'; Kind = 'Animal' } 55 | ) { 56 | It 'Returns ' { $true } 57 | 58 | It 'Has kind ' { $true } 59 | 60 | It 'Nested Hashtable TestCase ' { $true } -TestCases @{ 61 | Name = 'test' 62 | } 63 | It 'Nested Array TestCase <_>' { $true } -TestCases @( 64 | 'Test' 65 | ) 66 | It 'Nested Multiple Hashtable TestCase ' { $true } -TestCases @( 67 | @{ 68 | Name = 'Pester1' 69 | } 70 | @{ 71 | Name = 'Pester2' 72 | } 73 | ) 74 | 75 | Context 'Context Nested Foreach ' -ForEach @( 76 | @{ ContextValue = 'Test1' } 77 | @{ ContextValue = 'Test2' } 78 | ) { 79 | It 'Describe Context Nested Array <_>' -TestCases @( 80 | 'Test1' 81 | 'Test2' 82 | ) { $true } 83 | } 84 | } 85 | 86 | # Edge cases 87 | Context 'RootLevelContextWithTags' -Tag 'ContextTag', 'ContextTag2' { 88 | It 'ItTestWithTags' -Tag 'ItTag' { 89 | $true 90 | } 91 | } 92 | 93 | Context 'Long Running Test' { 94 | It 'Runs for 0.5 second' { 95 | Start-Sleep 0.5 96 | $true | Should -Be $true 97 | } 98 | It 'Runs for random' { 99 | Start-Sleep -Milliseconds (Get-Random -Min 500 -Max 2000) 100 | $true | Should -Be $true 101 | } 102 | It 'Runs for 1 second' { 103 | Start-Sleep 1 104 | $true | Should -Be $true 105 | } 106 | } 107 | # Describe 'Duplicate DescribeWithContext' { 108 | # Context 'DupeContext' { 109 | # It 'DupeContext' { $true } 110 | # } 111 | # } 112 | -------------------------------------------------------------------------------- /sample/Tests/BeforeAllError.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'TestDescribe' { 2 | BeforeAll { 3 | throw 'Fails in Describe Block' 4 | } 5 | It 'ShouldNotGetThisFar' { 6 | 'hi' 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /sample/Tests/ContextSyntaxError.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'test' { 2 | Context 'ok' { 3 | $1 ----or !== $2 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /sample/Tests/DescribeSyntaxError.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'test' { 2 | -------------------------------------------------------------------------------- /sample/Tests/DuplicateTestInfo.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'DuplicateTests' { 2 | It 'DuplicateTest' {} 3 | It 'DuplicateTest' {} 4 | } 5 | 6 | Describe 'DuplicateDescribes' { 7 | It 'Test' {} 8 | } 9 | Describe 'DuplicateDescribes' { 10 | It 'Test' {} 11 | } 12 | 13 | Describe 'DuplicateContexts' { 14 | Context 'DuplicateContext' { 15 | It 'Test' {} 16 | } 17 | Context 'DuplicateContext' { 18 | It 'Test2' {} 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /sample/Tests/EdgeCasesAndRegressions.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'Edge Cases and Regressions' { 2 | It 'HaveParameter' { 3 | (Get-Command Get-ChildItem) | Should -HaveParameter Include 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /sample/Tests/Empty.Tests.ps1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pester/vscode-adapter/c94e38efbdcfd39f8c0fcb50732ca77509c279e9/sample/Tests/Empty.Tests.ps1 -------------------------------------------------------------------------------- /sample/Tests/Mocks/BasicTests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "test", 4 | "id": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1:7", 5 | "file": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 6 | "startLine": 6, 7 | "endLine": 6, 8 | "label": "DupeContext", 9 | "result": 3, 10 | "duration": 95, 11 | "message": null, 12 | "expected": null, 13 | "actual": null, 14 | "targetFile": null, 15 | "targetLine": -1 16 | }, 17 | { 18 | "type": "test", 19 | "id": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1:12", 20 | "file": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 21 | "startLine": 11, 22 | "endLine": 11, 23 | "label": "DupeContext", 24 | "result": 3, 25 | "duration": 3, 26 | "message": null, 27 | "expected": null, 28 | "actual": null, 29 | "targetFile": null, 30 | "targetLine": -1 31 | }, 32 | { 33 | "type": "test", 34 | "id": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1:18", 35 | "file": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 36 | "startLine": 17, 37 | "endLine": 17, 38 | "label": "True", 39 | "result": 3, 40 | "duration": 13, 41 | "message": null, 42 | "expected": null, 43 | "actual": null, 44 | "targetFile": null, 45 | "targetLine": -1 46 | }, 47 | { 48 | "type": "test", 49 | "id": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1:19", 50 | "file": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 51 | "startLine": 18, 52 | "endLine": 18, 53 | "label": "False", 54 | "result": 3, 55 | "duration": 4, 56 | "message": null, 57 | "expected": null, 58 | "actual": null, 59 | "targetFile": null, 60 | "targetLine": -1 61 | }, 62 | { 63 | "type": "test", 64 | "id": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1:20", 65 | "file": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 66 | "startLine": 19, 67 | "endLine": 19, 68 | "label": "ShouldBeTrue", 69 | "result": 3, 70 | "duration": 50, 71 | "message": null, 72 | "expected": null, 73 | "actual": null, 74 | "targetFile": null, 75 | "targetLine": -1 76 | }, 77 | { 78 | "type": "test", 79 | "id": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1:23", 80 | "file": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 81 | "startLine": 22, 82 | "endLine": 22, 83 | "label": "Throws", 84 | "result": 4, 85 | "duration": 44, 86 | "message": "Kaboom", 87 | "expected": null, 88 | "actual": null, 89 | "targetFile": null, 90 | "targetLine": -1 91 | }, 92 | { 93 | "type": "test", 94 | "id": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1:24", 95 | "file": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 96 | "startLine": 23, 97 | "endLine": 23, 98 | "label": "ShouldThrow", 99 | "result": 4, 100 | "duration": 36, 101 | "message": "Expected an exception, to be thrown, but no exception was thrown.", 102 | "expected": "an exception, to be thrown", 103 | "actual": "no exception was thrown", 104 | "targetFile": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 105 | "targetLine": 23 106 | }, 107 | { 108 | "type": "test", 109 | "id": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1:25", 110 | "file": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 111 | "startLine": 24, 112 | "endLine": 24, 113 | "label": "ShouldBeTrue", 114 | "result": 4, 115 | "duration": 12, 116 | "message": "Expected $true, but got $false.", 117 | "expected": "$true", 118 | "actual": "$false", 119 | "targetFile": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 120 | "targetLine": 24 121 | }, 122 | { 123 | "type": "test", 124 | "id": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1:26", 125 | "file": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 126 | "startLine": 25, 127 | "endLine": 25, 128 | "label": "ShouldBeGreaterThan", 129 | "result": 4, 130 | "duration": 25, 131 | "message": "Expected the actual value to be greater than 2, but got 1.", 132 | "expected": "the actual value to be greater than 2", 133 | "actual": "1", 134 | "targetFile": "C:\\Users\\JGrote\\Projects\\vscode-pester-test-adapter\\sample\\Tests\\Basic.Tests.ps1", 135 | "targetLine": 25 136 | } 137 | ] 138 | -------------------------------------------------------------------------------- /sample/Tests/Mocks/Block.clixml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pester.Block 5 | System.Object 6 | 7 | [+] Describe Nested Foreach <name> 8 | 9 | Describe Nested Foreach <name> 10 | 11 | 12 | System.Collections.Generic.List`1[[System.String, System.Private.CoreLib, Version=5.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]] 13 | System.Object 14 | 15 | 16 | Describe Nested Foreach <name> 17 | 18 | 19 | 20 | 21 | System.Collections.Hashtable 22 | System.Object 23 | 24 | 25 | 26 | Name 27 | giraffe 28 | 29 | 30 | Symbol 31 | _xD83E__xDD92_ 32 | 33 | 34 | Kind 35 | Animal 36 | 37 | 38 | 39 | Describe Nested Foreach giraffe 40 | Describe Nested Foreach giraffe 41 | 42 | 43 | System.Collections.Generic.List`1[[Pester.Block, Pester, Version=5.2.2.0, Culture=neutral, PublicKeyToken=null]] 44 | System.Object 45 | 46 | 47 | 48 | 49 | 50 | System.Collections.Generic.List`1[[Pester.Test, Pester, Version=5.2.2.0, Culture=neutral, PublicKeyToken=null]] 51 | System.Object 52 | 53 | 54 | 55 | 56 | Pester.Test 57 | System.Object 58 | 59 | [+] Returns _xD83E__xDD92_ 60 | 61 | Returns <symbol> 62 | 63 | 64 | 65 | Describe Nested Foreach <name> 66 | Returns <symbol> 67 | 68 | 69 | 70 | Returns _xD83E__xDD92_ 71 | Describe Nested Foreach giraffe.Returns _xD83E__xDD92_ 72 | Passed 73 | 74 | 75 | System.Collections.Generic.List`1[[System.Object, System.Private.CoreLib, Version=5.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]] 76 | System.Object 77 | 78 | 79 | 80 | true 81 | PT0.0013703S 82 | Test 83 | 84 | $true 85 | 86 | false 87 | false 88 | [+] Describe Nested Foreach <name> 89 | true 90 | false 91 | false 92 | false 93 | false 94 | true 95 | 47 96 | true 97 |
2021-06-20T18:15:09.6696985-07:00
98 | true 99 | false 100 | PT0.0002997S 101 | PT0.0010706S 102 | 103 | 104 |
105 |
106 | 107 | 108 | [+] Has kind Animal 109 | 110 | Has kind <kind> 111 | 112 | 113 | 114 | Describe Nested Foreach <name> 115 | Has kind <kind> 116 | 117 | 118 | 119 | Has kind Animal 120 | Describe Nested Foreach giraffe.Has kind Animal 121 | Passed 122 | 123 | 124 | 125 | 126 | true 127 | PT0.0012612S 128 | Test 129 | 130 | $true 131 | 132 | false 133 | false 134 | [+] Describe Nested Foreach <name> 135 | false 136 | false 137 | false 138 | false 139 | false 140 | true 141 | 49 142 | true 143 |
2021-06-20T18:15:09.671112-07:00
144 | true 145 | false 146 | PT0.000283S 147 | PT0.0009782S 148 | 149 | 150 |
151 |
152 | 153 | 154 | [+] Nested Hashtable TestCase Animal test 155 | 156 | Nested Hashtable TestCase <kind> <name> 157 | 158 | 159 | 160 | Describe Nested Foreach <name> 161 | Nested Hashtable TestCase <kind> <name> 162 | 163 | 164 | 165 | 166 | 167 | 168 | Name 169 | test 170 | 171 | 172 | 173 | Nested Hashtable TestCase Animal test 174 | Describe Nested Foreach giraffe.Nested Hashtable TestCase Animal test 175 | Passed 176 | 177 | 178 | 179 | 180 | true 181 | PT0.0014348S 182 | Test 183 | 51 184 | $true 185 | 186 | false 187 | false 188 | [+] Describe Nested Foreach <name> 189 | false 190 | false 191 | false 192 | false 193 | false 194 | true 195 | 51 196 | true 197 |
2021-06-20T18:15:09.6724192-07:00
198 | true 199 | false 200 | PT0.0003259S 201 | PT0.0011089S 202 | 203 | 204 |
205 |
206 | 207 | 208 | [+] Nested Array TestCase Animal Test 209 | 210 | Nested Array TestCase <kind> <_> 211 | 212 | 213 | 214 | Describe Nested Foreach <name> 215 | Nested Array TestCase <kind> <_> 216 | 217 | 218 | Test 219 | Nested Array TestCase Animal Test 220 | Describe Nested Foreach giraffe.Nested Array TestCase Animal Test 221 | Passed 222 | 223 | 224 | 225 | 226 | true 227 | PT0.001449S 228 | Test 229 | 54 230 | $true 231 | 232 | false 233 | false 234 | [+] Describe Nested Foreach <name> 235 | false 236 | false 237 | false 238 | false 239 | false 240 | true 241 | 54 242 | true 243 |
2021-06-20T18:15:09.6739039-07:00
244 | true 245 | false 246 | PT0.0003781S 247 | PT0.0010709S 248 | 249 | 250 |
251 |
252 | 253 | 254 | [+] Nested Multiple Hashtable TestCase Animal Pester1 255 | 256 | Nested Multiple Hashtable TestCase <kind> <name> 257 | 258 | 259 | 260 | Describe Nested Foreach <name> 261 | Nested Multiple Hashtable TestCase <kind> <name> 262 | 263 | 264 | 265 | 266 | 267 | 268 | Name 269 | Pester1 270 | 271 | 272 | 273 | Nested Multiple Hashtable TestCase Animal Pester1 274 | Describe Nested Foreach giraffe.Nested Multiple Hashtable TestCase Animal Pester1 275 | Passed 276 | 277 | 278 | 279 | 280 | true 281 | PT0.008073S 282 | Test 283 | 57 284 | $true 285 | 286 | false 287 | false 288 | [+] Describe Nested Foreach <name> 289 | false 290 | false 291 | false 292 | false 293 | false 294 | true 295 | 57 296 | true 297 |
2021-06-20T18:15:09.6754-07:00
298 | true 299 | false 300 | PT0.0003789S 301 | PT0.0076941S 302 | 303 | 304 |
305 |
306 | 307 | 308 | [+] Nested Multiple Hashtable TestCase Animal Pester2 309 | 310 | Nested Multiple Hashtable TestCase <kind> <name> 311 | 312 | 313 | 314 | Describe Nested Foreach <name> 315 | Nested Multiple Hashtable TestCase <kind> <name> 316 | 317 | 318 | 319 | 320 | 321 | 322 | Name 323 | Pester2 324 | 325 | 326 | 327 | Nested Multiple Hashtable TestCase Animal Pester2 328 | Describe Nested Foreach giraffe.Nested Multiple Hashtable TestCase Animal Pester2 329 | Passed 330 | 331 | 332 | 333 | 334 | true 335 | PT0.0015562S 336 | Test 337 | 57 338 | $true 339 | 340 | false 341 | false 342 | [+] Describe Nested Foreach <name> 343 | false 344 | true 345 | false 346 | false 347 | false 348 | true 349 | 57 350 | true 351 |
2021-06-20T18:15:09.683595-07:00
352 | true 353 | false 354 | PT0.000338S 355 | PT0.0012182S 356 | 357 | 358 |
359 |
360 |
361 |
362 | Passed 363 | 0 364 | 6 365 | 0 366 | 0 367 | 6 368 | 369 | 370 | 371 | 372 | PT0.3980479S 373 | 374 | 375 | 376 | 377 | 378 | false 379 | false 380 | Block 381 | 382 | 383 | Pester.ContainerInfo 384 | System.Object 385 | 386 | Pester.ContainerInfo 387 | 388 | File 389 | 390 | C:\Users\JGrote\Projects\vscode-adapter\sample\Tests\Basic.Tests.ps1 391 | 392 | Microsoft.PowerShell.Core\FileSystem::C:\Users\JGrote\Projects\vscode-adapter\sample\Tests\Basic.Tests.ps1 393 | Microsoft.PowerShell.Core\FileSystem::C:\Users\JGrote\Projects\vscode-adapter\sample\Tests 394 | Basic.Tests.ps1 395 | C 396 | Microsoft.PowerShell.Core\FileSystem 397 | false 398 | C:\Users\JGrote\Projects\vscode-adapter\sample\Tests\Basic.Tests.ps1 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | [ ] Root 407 | 408 | Root 409 | 410 | 411 | 412 | Path 413 | 414 | 415 | 416 | Root 417 | Path 418 | 419 | 420 | 421 | [+] Test 422 | [-] Basic 423 | [+] TestCases 424 | [+] Describe Nested Foreach <name> 425 | [+] Describe Nested Foreach <name> 426 | [ ] Empty Describe 427 | [ ] Duplicate Describe 428 | [ ] Duplicate Describe 429 | [+] Duplicate DescribeWithContext 430 | 431 | 432 | 433 | 434 | 435 | 436 | NotRun 437 | 5 438 | 26 439 | 0 440 | 0 441 | 31 442 | 443 | 444 | 445 | 446 | PT0.5450942S 447 | 448 | 449 | false 450 | false 451 | Block 452 | 453 | 454 | true 455 | 456 | 457 | _x000D__x000A_ . $action $parameters_x000D__x000A_ if ($null -ne $setup) {_x000D__x000A_ . $setup_x000D__x000A_ }_x000D__x000A_ 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | [+] Test 468 | [-] Basic 469 | [+] TestCases 470 | [+] Describe Nested Foreach <name> 471 | [+] Describe Nested Foreach <name> 472 | [ ] Empty Describe 473 | [ ] Duplicate Describe 474 | [ ] Duplicate Describe 475 | [+] Duplicate DescribeWithContext 476 | 477 | 478 | false 479 | true 480 | true 481 | 482 | true 483 | true 484 |
2021-06-20T18:15:09.290009-07:00
485 | false 486 | false 487 | false 488 | PT0.0257214S 489 | PT0.4670464S 490 | PT0.0523264S 491 | -PT2.4483944S 492 | 493 | 0 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | TestRegistry 503 | 504 | 505 | 506 | 507 | TestRegistryAdded 508 | true 509 | 510 | 511 | TestRegistryContent 512 | 513 | 514 | 515 | 516 | 517 | 518 | Mock 519 | 520 | 521 | 522 | 523 | Hooks 524 | 525 | 526 | 527 | 528 | 529 | 530 | Behaviors 531 | 532 | 533 | 534 | 535 | 536 | 537 | CallHistory 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | TestDrive 548 | 549 | 550 | 551 | 552 | TestDriveContent 553 | 554 | 555 | 556 | TestDriveAdded 557 | true 558 | 559 | 560 | 561 | 562 | 563 | 564 | 0 565 | 0 566 | true 567 | 0 568 | 0 569 | 0 570 | 0 571 | 0 572 | 0 573 | 0 574 |
575 |
576 | false 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | true 598 | false 599 | false 600 | 601 | true 602 | true 603 |
2021-06-20T18:15:09.6546799-07:00
604 | false 605 | false 606 | false 607 | PT0S 608 | PT0.34697S 609 | PT0.0510779S 610 | PT0.3829034S 611 | _x000D__x000A_ It 'Returns <symbol>' { $true }_x000D__x000A__x000D__x000A_ It 'Has kind <kind>' { $true }_x000D__x000A__x000D__x000A_ It 'Nested Hashtable TestCase <kind> <name>' { $true } -TestCases @{_x000D__x000A_ Name = 'test'_x000D__x000A_ }_x000D__x000A_ It 'Nested Array TestCase <kind> <_>' { $true } -TestCases @(_x000D__x000A_ 'Test'_x000D__x000A_ )_x000D__x000A_ It 'Nested Multiple Hashtable TestCase <kind> <name>' { $true } -TestCases @(_x000D__x000A_ @{_x000D__x000A_ Name = 'Pester1'_x000D__x000A_ }_x000D__x000A_ @{_x000D__x000A_ Name = 'Pester2'_x000D__x000A_ }_x000D__x000A_ )_x000D__x000A_ 612 | 43 613 | 614 | 615 | 0 616 | 0 617 | true 618 | 6 619 | 6 620 | 0 621 | 0 622 | 0 623 | 0 624 | 0 625 |
626 |
627 |
-------------------------------------------------------------------------------- /sample/Tests/Mocks/StrictMode.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version 3 2 | 'Strict Mode' 3 | -------------------------------------------------------------------------------- /sample/Tests/StrictMode.Tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | Set-StrictMode -Version Latest 3 | } 4 | 5 | Describe 'Tests' { 6 | It 'Should-Pass' { 7 | # This test should pass. 8 | . $PSScriptRoot/Mocks/StrictMode.ps1 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /sample/Tests/Test With Space.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'Test with Space' { 2 | It 'Loads' {} 3 | } 4 | -------------------------------------------------------------------------------- /sample/Tests/True.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe 'SimpleTest' { 2 | It 'True should be true' { 3 | $true | Should -Be $true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/dotnetNamedPipeServer.ts: -------------------------------------------------------------------------------- 1 | import { createServer, type Server, type Socket } from 'net' 2 | import { platform, tmpdir } from 'os' 3 | import { join } from 'path' 4 | import { type Disposable } from 'vscode' 5 | 6 | /** Provides a simple server listener to a .NET named pipe. This is useful as a IPC method to child processes like a PowerShell Script */ 7 | export class DotnetNamedPipeServer implements Disposable { 8 | private readonly server: Server 9 | constructor( 10 | public name: string = 'NodeNamedPipe-' + Math.random().toString(36) 11 | ) { 12 | this.server = createServer() 13 | } 14 | 15 | /** Starts the server listening on the specified named pipe */ 16 | async listen() { 17 | return new Promise((resolve, reject) => { 18 | this.server 19 | .listen(DotnetNamedPipeServer.getDotnetPipePath(this.name)) 20 | .once('listening', resolve) 21 | .once('error', reject) 22 | }) 23 | } 24 | 25 | /** Will return a socket once a connection is provided. WARNING: If you set multiple listeners they will all get the 26 | * same socket, it is not sequential 27 | */ 28 | async waitForConnection() { 29 | if (!this.server.listening) { 30 | await this.listen() 31 | } 32 | return new Promise((resolve, reject) => { 33 | this.server.once('connection', resolve) 34 | this.server.once('error', reject) 35 | }) 36 | } 37 | 38 | /** Takes the name of a pipe and translates it to the common location it would be found if created with that same 39 | * name using the .NET NamedPipeServer class. The path is different depending on the OS. 40 | */ 41 | static getDotnetPipePath(pipeName: string) { 42 | if (platform() === 'win32') { 43 | return '\\\\.\\pipe\\' + pipeName 44 | } else { 45 | // Windows uses NamedPipes where non-Windows platforms use Unix Domain Sockets. 46 | // This requires connecting to the pipe file in different locations on Windows vs non-Windows. 47 | return join(tmpdir(), `CoreFxPipe_${pipeName}`) 48 | } 49 | } 50 | 51 | dispose() { 52 | this.server.close() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { type ExtensionContext, window, workspace, Disposable, WorkspaceConfiguration, Extension } from 'vscode' 2 | import { 3 | waitForPowerShellExtension, 4 | PowerShellExtensionClient, 5 | IPowerShellExtensionClient 6 | } from './powershellExtensionClient' 7 | import { watchWorkspace } from './workspaceWatcher' 8 | import log, { VSCodeLogOutputChannelTransport } from './log' 9 | 10 | export async function activate(context: ExtensionContext) { 11 | 12 | log.attachTransport(new VSCodeLogOutputChannelTransport('Pester').transport) 13 | 14 | subscriptions = context.subscriptions 15 | 16 | // PowerShell extension is a prerequisite 17 | const powershellExtension = await waitForPowerShellExtension() 18 | pesterExtensionContext = { 19 | extensionContext: context, 20 | powerShellExtension: powershellExtension, 21 | powershellExtensionPesterConfig: PowerShellExtensionClient.GetPesterSettings() 22 | } 23 | 24 | promptForPSLegacyCodeLensDisable() 25 | 26 | await watchWorkspace() 27 | 28 | // TODO: Rig this up for multiple workspaces 29 | // const stopPowerShellCommand = commands.registerCommand('pester.stopPowershell', () => { 30 | // if (controller.stopPowerShell()) { 31 | // void window.showInformationMessage('PowerShell background process stopped.') 32 | // } else { 33 | // void window.showWarningMessage('No PowerShell background process was running !') 34 | // } 35 | // }) 36 | 37 | // context.subscriptions.push( 38 | // controller, 39 | // stopPowerShellCommand, 40 | // ) 41 | 42 | } 43 | 44 | /** Register a Disposable with the extension so that it can be cleaned up if the extension is disabled */ 45 | export function registerDisposable(disposable: Disposable) { 46 | if (subscriptions == undefined) { 47 | throw new Error('registerDisposable called before activate. This should never happen and is a bug.') 48 | } 49 | subscriptions.push(disposable) 50 | } 51 | 52 | export function registerDisposables(disposables: Disposable[]) { 53 | subscriptions.push(Disposable.from(...disposables)) 54 | } 55 | 56 | let subscriptions: Disposable[] 57 | 58 | type PesterExtensionContext = { 59 | extensionContext: ExtensionContext 60 | powerShellExtension: Extension 61 | powershellExtensionPesterConfig: WorkspaceConfiguration 62 | } 63 | 64 | /** Get the activated extension context */ 65 | export function getPesterExtensionContext() { 66 | if (pesterExtensionContext == undefined) { 67 | throw new Error('Pester Extension Context attempted to be fetched before activation. This should never happen and is a bug') 68 | } 69 | 70 | return pesterExtensionContext 71 | } 72 | let pesterExtensionContext: PesterExtensionContext 73 | 74 | function promptForPSLegacyCodeLensDisable() { 75 | // Disable PowerShell codelens setting if present 76 | const powershellExtensionConfig = PowerShellExtensionClient.GetPesterSettings() 77 | 78 | const psExtensionCodeLensSetting: boolean = powershellExtensionConfig.codeLens 79 | 80 | const suppressCodeLensNotice = workspace.getConfiguration('pester').get('suppressCodeLensNotice') ?? false 81 | 82 | if (psExtensionCodeLensSetting && !suppressCodeLensNotice) { 83 | void window.showInformationMessage( 84 | 'The Pester Tests extension recommends disabling the built-in PowerShell Pester CodeLens. Would you like to do this?', 85 | 'Yes', 86 | 'Workspace Only', 87 | 'No', 88 | 'Dont Ask Again' 89 | ).then(async response => { 90 | switch (response) { 91 | case 'No': { 92 | return 93 | } 94 | case 'Yes': { 95 | await powershellExtensionConfig.update('codeLens', false, true) 96 | break 97 | } 98 | case 'Workspace Only': { 99 | await powershellExtensionConfig.update('codeLens', false, false) 100 | break 101 | } 102 | case 'Dont Ask Again': { 103 | await workspace.getConfiguration('pester').update('suppressCodeLensNotice', true, true) 104 | break 105 | } 106 | } 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/features/toggleAutoRunOnSaveCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | commands, 3 | StatusBarAlignment, 4 | type TextEditor, 5 | ThemeColor, 6 | window, 7 | workspace 8 | } from 'vscode' 9 | 10 | function getPesterConfig() { 11 | return workspace.getConfiguration('pester') 12 | } 13 | 14 | function getPesterAutoRunSaveStatus() { 15 | return getPesterConfig().get('autoRunOnSave') ?? true 16 | } 17 | 18 | function getPesterAutoRunStatusMessage(autoRunEnabled: boolean) { 19 | const debugOnSaveEnabled = getPesterConfig().get('autoDebugOnSave') ?? false 20 | const autoRunStatus = autoRunEnabled ? 'enabled' : 'disabled' 21 | const message = `Pester Auto Run on Save is now ${autoRunStatus} for this workspace` 22 | return debugOnSaveEnabled && autoRunEnabled 23 | ? message + ' (Auto Debug is active)' 24 | : message 25 | } 26 | 27 | function updatePesterStatusBar(saveStatus: boolean) { 28 | autoRunStatusBarItem.backgroundColor = saveStatus 29 | ? undefined 30 | : new ThemeColor('statusBarItem.warningBackground') 31 | const runText = getPesterConfig().get('autoDebugOnSave') ?? false 32 | ? '$(bug) Pester' 33 | : '$(beaker) Pester' 34 | autoRunStatusBarItem.text = saveStatus ? runText : '$(debug-pause) Pester' 35 | } 36 | 37 | const toggleAutoRunOnSaveHandler = () => { 38 | // Race condition between update and get even with the thenable, save off the status instead 39 | const newAutoRunStatus = !getPesterAutoRunSaveStatus() 40 | void getPesterConfig() 41 | // Update with undefined means return to default value which is true 42 | .update('autoRunOnSave', newAutoRunStatus ? undefined : false) 43 | .then(() => { 44 | updatePesterStatusBar(newAutoRunStatus) 45 | void window.showInformationMessage( 46 | getPesterAutoRunStatusMessage(newAutoRunStatus) 47 | ) 48 | }) 49 | } 50 | 51 | export const toggleAutoRunOnSaveCommand = commands.registerCommand( 52 | 'pester.toggleAutoRunOnSave', 53 | toggleAutoRunOnSaveHandler 54 | ) 55 | 56 | export const autoRunStatusBarItem = window.createStatusBarItem( 57 | StatusBarAlignment.Right, 58 | 0.99 // Powershell is priority 1, we want it to be just to the right of Powershell 59 | ) 60 | autoRunStatusBarItem.command = 'pester.toggleAutoRunOnSave' 61 | autoRunStatusBarItem.name = 'Pester' 62 | autoRunStatusBarItem.text = '$(debug-restart)Pester' 63 | autoRunStatusBarItem.backgroundColor = new ThemeColor( 64 | 'statusBarItem.warningBackground' 65 | ) 66 | autoRunStatusBarItem.tooltip = 'Click me to toggle Pester Test Auto-Run on Save' 67 | 68 | function showStatusBarIfPowershellDocument(textEditor: TextEditor | undefined) { 69 | if (textEditor === undefined) { 70 | autoRunStatusBarItem.hide() 71 | return 72 | } 73 | textEditor.document.languageId === 'powershell' 74 | ? autoRunStatusBarItem.show() 75 | : autoRunStatusBarItem.hide() 76 | } 77 | 78 | // Initialize 79 | showStatusBarIfPowershellDocument(window.activeTextEditor) 80 | 81 | export function initialize() { 82 | updatePesterStatusBar(getPesterAutoRunSaveStatus()) 83 | } 84 | 85 | export const autoRunStatusBarVisibleEvent = window.onDidChangeActiveTextEditor( 86 | showStatusBarIfPowershellDocument 87 | ) 88 | 89 | export const updateAutoRunStatusBarOnConfigChange = 90 | workspace.onDidChangeConfiguration(e => { 91 | if (e.affectsConfiguration('pester')) { 92 | updatePesterStatusBar(getPesterAutoRunSaveStatus()) 93 | } 94 | }) 95 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Logger, ILogObj } from 'tslog' 3 | import { ILogObjMeta } from 'tslog/dist/types/BaseLogger' 4 | import { LogOutputChannel, window } from 'vscode' 5 | 6 | interface DefaultLog extends ILogObj { 7 | args: unknown[] 8 | } 9 | 10 | /** Represents the default TS Log levels. This is not explicitly provided by tslog */ 11 | type DefaultTSLogLevel = 12 | "SILLY" 13 | | "TRACE" 14 | | "DEBUG" 15 | | "INFO" 16 | | "WARN" 17 | | "ERROR" 18 | | "FATAL" 19 | 20 | export class VSCodeLogOutputChannelTransport { 21 | /** Used to ensure multiple registered transports that request the same name use the same output window. NOTE: You can still get duplicate windows if you register channels outside this transport */ 22 | private static readonly channels = new Map() 23 | private readonly name: string 24 | constructor(name: string) { 25 | this.name = name 26 | } 27 | 28 | get channel() { 29 | const newChannel = VSCodeLogOutputChannelTransport.channels.has(this.name) 30 | ? VSCodeLogOutputChannelTransport.channels.get(this.name) 31 | : ( 32 | VSCodeLogOutputChannelTransport.channels 33 | .set(this.name, window.createOutputChannel(this.name, { log: true })) 34 | .get(this.name) 35 | ) 36 | if (newChannel === undefined) { 37 | throw new Error("Failed to create output channel. This is a bug and should never happen.") 38 | } 39 | return newChannel 40 | } 41 | 42 | /** Wire this up to Logger.AttachTransport 43 | * 44 | * @example 45 | * ``` 46 | * logger.attachTransport((new VSCodeLogOutputChannelTransport('myExtensionName')).transport) 47 | * ``` 48 | */ 49 | public transport = (log: T) => { 50 | const message = typeof log.args[0] === "string" 51 | ? log.args[0] 52 | : JSON.stringify(log.args[0]) 53 | const args = log.args.slice(1) 54 | switch (log._meta.logLevelName as DefaultTSLogLevel) { 55 | case 'SILLY': this.channel.trace(message, ...args); break 56 | case 'TRACE': this.channel.trace(message, ...args); break 57 | case 'DEBUG': this.channel.debug(message, ...args); break 58 | case 'INFO': this.channel.info(message, ...args); break 59 | case 'WARN': this.channel.warn(message, ...args); break 60 | case 'ERROR': this.channel.error(message, ...args); break 61 | case 'FATAL': this.channel.error(message, ...args); break 62 | default: throw new Error(`Unknown log level: ${log._meta.logLevelName}`) 63 | } 64 | } 65 | } 66 | 67 | /** A global logger using tslog to use within the extension. You must attach transports to enable logging 68 | * Logging Examples: 69 | * Log to nodejs console when debugging 70 | 71 | if (process.env.VSCODE_DEBUG_MODE === 'true') { 72 | log.attachTransport(new ConsoleLogTransport()) 73 | } 74 | 75 | Log to vscode output channel 76 | 77 | log.attachTransport(new VSCodeOutputChannelTransport('Pester')) 78 | */ 79 | const log = new Logger({ 80 | name: 'default', 81 | type: 'pretty', 82 | prettyErrorLoggerNameDelimiter: "-", 83 | prettyErrorParentNamesSeparator: "-", 84 | stylePrettyLogs: true, 85 | argumentsArrayName: "args", 86 | overwrite: { 87 | transportFormatted: () => { return } // We want pretty formatting but no default output 88 | } 89 | }) 90 | 91 | export default log 92 | -------------------------------------------------------------------------------- /src/log.vscode.test.ts: -------------------------------------------------------------------------------- 1 | import log, { VSCodeLogOutputChannelTransport } from "./log" 2 | describe('log', () => { 3 | it('should be able to log', () => { 4 | const transport = new VSCodeLogOutputChannelTransport('test') 5 | log.attachTransport(transport.transport) 6 | log.warn('test') 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/pesterTestController.ts: -------------------------------------------------------------------------------- 1 | import { join, isAbsolute, dirname } from 'path' 2 | import { 3 | Disposable, 4 | Extension, 5 | ExtensionContext, 6 | Location, 7 | MarkdownString, 8 | Position, 9 | TestController, 10 | TestItem, 11 | TestMessage, 12 | TestRun, 13 | TestRunProfile, 14 | TestRunProfileKind, 15 | TestRunRequest, 16 | tests, 17 | TestTag, 18 | Uri, 19 | window, 20 | workspace, 21 | languages, 22 | FileSystemWatcher, 23 | CancellationToken, 24 | WorkspaceFolder, 25 | TextDocument, 26 | RelativePattern, 27 | DocumentSelector, 28 | WorkspaceConfiguration, 29 | } from 'vscode' 30 | import { DotnetNamedPipeServer } from './dotnetNamedPipeServer' 31 | import { default as parentLog } from './log' 32 | import { 33 | TestData, 34 | TestDefinition, 35 | TestFile, 36 | TestResult, 37 | getRange, 38 | } from './pesterTestTree' 39 | import { PowerShell, PowerShellError, PSOutput } from './powershell' 40 | import { 41 | IPowerShellExtensionClient, 42 | PowerShellExtensionClient 43 | } from './powershellExtensionClient' 44 | import { clear, findTestItem, forAll, getTestItems, getUniqueTestItems, isTestItemOptions } from './util/testItemUtils' 45 | import debounce = require('debounce-promise') 46 | import { isDeepStrictEqual } from 'util' 47 | import { getPesterExtensionContext } from './extension' 48 | import { watchWorkspaceFolder } from './workspaceWatcher' 49 | 50 | const defaultControllerLabel = 'Pester' 51 | 52 | /** Used to store the first controller in the system so it can be renamed if multiple controllers are instantiated */ 53 | let firstTestController: [string, TestController] 54 | let firstTestControllerRenamed = false 55 | 56 | /** Used to provide a lazily initialized singleton PowerShell extension client */ 57 | let powerShellExtensionClient: PowerShellExtensionClient | undefined 58 | async function getPowerShellExtensionClient() { 59 | return powerShellExtensionClient ??= await PowerShellExtensionClient.create( 60 | getPesterExtensionContext().extensionContext, 61 | getPesterExtensionContext().powerShellExtension 62 | ) 63 | } 64 | 65 | /** A wrapper for the vscode TestController API specific to PowerShell Pester Test Suite. 66 | */ 67 | export class PesterTestController implements Disposable { 68 | private ps: PowerShell | undefined 69 | /** Queues up testItems from resolveHandler requests because pester works faster scanning multiple files together **/ 70 | private discoveryQueue = new Set() 71 | private readonly testRunStatus = new Map() 72 | private testFileWatchers = new Map() 73 | private get testFilePatterns(): ReadonlyArray { return Array.from(this.testFileWatchers.keys()) } 74 | private readonly continuousRunTests = new Set() 75 | private readonly disposables = new Array() 76 | private runProfile?: TestRunProfile 77 | private debugProfile?: TestRunProfile 78 | private readonly powershellExtension: Extension 79 | get powerShellExtensionClientPromise() { return getPowerShellExtensionClient() } 80 | private readonly context: ExtensionContext 81 | 82 | // pipe for PSIC communication should be lazy initialized 83 | private _returnServer?: DotnetNamedPipeServer 84 | private get returnServer(): DotnetNamedPipeServer { 85 | return this._returnServer ??= new DotnetNamedPipeServer( 86 | 'VSCodePester' + process.pid + '' + this.workspaceFolder.index 87 | ) 88 | } 89 | 90 | private _config?: WorkspaceConfiguration 91 | private get config(): WorkspaceConfiguration { 92 | return this._config ??= workspace.getConfiguration('pester', this.workspaceFolder.uri) 93 | } 94 | 95 | // We want our "inner" vscode testController to be lazily initialized on first request so it doesn't show in the UI unless there are relevant test files 96 | private _testController: TestController | undefined 97 | public get testController(): TestController { return this._testController ??= this.createTestController() } 98 | 99 | constructor( 100 | public readonly workspaceFolder: WorkspaceFolder, 101 | public readonly label: string = `${defaultControllerLabel}: ${workspaceFolder.name}`, 102 | public readonly log = parentLog.getSubLogger({ 103 | name: workspaceFolder.name 104 | }) 105 | ) { 106 | const pesterExtensionContext = getPesterExtensionContext() 107 | this.context = pesterExtensionContext.extensionContext 108 | this.powershellExtension = pesterExtensionContext.powerShellExtension 109 | 110 | /** Remove the controller if the matching workspace is removed in vscode */ 111 | const onWorkspaceFolderRemoved = workspace.onDidChangeWorkspaceFolders( 112 | // This should only match once 113 | e => e.removed.filter( 114 | f => f === workspaceFolder 115 | ).forEach(() => { 116 | onWorkspaceFolderRemoved.dispose() 117 | this.dispose() 118 | }, this) 119 | ) 120 | } 121 | 122 | /** Creates a managed vscode instance of our test controller and wires up the appropraite handlers */ 123 | private createTestController() { 124 | const testController = tests.createTestController( 125 | `${this.context.extension.id}-${this.workspaceFolder.uri.toString()}`, 126 | this.label 127 | ) 128 | testController.refreshHandler = this.refreshHandler.bind(this) 129 | testController.resolveHandler = this.resolveHandler.bind(this) 130 | this.runProfile = testController.createRunProfile( 131 | 'Dedicated Pester PowerShell Instance', 132 | TestRunProfileKind.Run, 133 | this.testHandler.bind(this), 134 | true, 135 | undefined, 136 | true 137 | ) 138 | this.debugProfile = testController.createRunProfile( 139 | 'Dedicated Pester PowerShell Instance', 140 | TestRunProfileKind.Debug, 141 | this.testHandler.bind(this), 142 | true 143 | ) 144 | this.registerDisposable(testController) 145 | 146 | /** The first controller should simply be named 'Pester' and not include the workspace name in a single root 147 | * workspace. By default this is hidden if no other non-Pester test controllers but keeps it simple if there are 148 | * other controllers. In a multi-root workspace, we want to include the workspace name in the label to differentiate 149 | * between controllers. 150 | */ 151 | if (firstTestController === undefined) { 152 | firstTestController = [this.label, testController] 153 | testController.label = defaultControllerLabel 154 | } else { 155 | if (firstTestControllerRenamed === false) { 156 | firstTestController[1].label = firstTestController[0] 157 | firstTestControllerRenamed = true 158 | } 159 | } 160 | return testController 161 | } 162 | 163 | /** Initializes file system watchers for the workspace and checks for Pester files in open windows */ 164 | async watch(cancelToken?: CancellationToken) { 165 | const watchers = await watchWorkspaceFolder(this.workspaceFolder) 166 | this.testFileWatchers = watchers 167 | 168 | this.log.info(`Watching for Pester file changes in ${this.workspaceFolder.uri.fsPath}`) 169 | this.registerDisposable(...Array.from(watchers.values()).flatMap( 170 | watcher => { 171 | return [ 172 | watcher.onDidChange(this.onFileChanged, this), 173 | watcher.onDidCreate(this.onFileAdded, this), 174 | watcher.onDidDelete(this.onFileDeleted, this) 175 | ] 176 | } 177 | )) 178 | 179 | // Watch for new open documents and initiate a test refresh 180 | this.registerDisposable( 181 | workspace.onDidOpenTextDocument(this.refreshIfPesterTestDocument) 182 | ) 183 | 184 | // Do a test discovery if a pester document is already open 185 | if (window.activeTextEditor?.document !== undefined) { 186 | this.refreshIfPesterTestDocument(window.activeTextEditor.document) 187 | } 188 | 189 | await this.findPesterFiles(cancelToken) 190 | } 191 | 192 | private async findPesterFiles(cancelToken?: CancellationToken) { 193 | this.log.info('Scanning workspace for Pester files:', this.workspaceFolder.uri.fsPath) 194 | const detectedPesterFiles = (await Promise.all(this.testFilePatterns.map( 195 | pattern => { 196 | this.log.debug('Scanning for files matching pattern:', pattern.baseUri, pattern.pattern) 197 | return workspace.findFiles(pattern, undefined, undefined, cancelToken) 198 | } 199 | ))).flat() 200 | 201 | if (cancelToken?.isCancellationRequested) { return } 202 | 203 | detectedPesterFiles.forEach(uri => this.onFileAdded(uri)) 204 | } 205 | 206 | refreshIfPesterTestDocument( 207 | doc: TextDocument, 208 | documentSelector: DocumentSelector = this.testFilePatterns 209 | ) { 210 | if ( 211 | // TODO: Support virtual pester test files by running them as a scriptblock 212 | doc.uri.scheme === 'file' && 213 | languages.match(documentSelector, doc) 214 | ) { 215 | this.refreshTests(doc.uri) 216 | } 217 | } 218 | 219 | onFileAdded(file: Uri) { 220 | this.log.info('Detected New Pester File: ', file.fsPath) 221 | TestFile.getOrCreate(this.testController, file) 222 | } 223 | onFileChanged(file: Uri) { 224 | this.log.info('Detected Pester File Change: ', file.fsPath) 225 | this.refreshTests(file) 226 | } 227 | onFileDeleted(file: Uri) { 228 | this.log.info('Detected Pester File Deletion: ', file.fsPath) 229 | const deletedTestItem = Array.from(getUniqueTestItems(this.testController.items)).find(item => item.uri === file) 230 | if (deletedTestItem) { 231 | this.testController.items.delete(deletedTestItem.id) 232 | } else { 233 | this.log.error('A file that matches the pester test item was deleted but could not find a match in the controller items. This is probably a bug: ', file.fsPath) 234 | } 235 | } 236 | 237 | /** The test controller API calls this whenever it needs to get the resolveChildrenHandler 238 | * for Pester, this is only relevant to TestFiles as this is pester's lowest level of test resolution 239 | * @param testItem - The test item to get the resolveChildrenHandler for 240 | * @param force - If true, force the test to be re-resolved 241 | */ 242 | private async resolveHandler( 243 | testItem: TestItem | undefined, 244 | token?: CancellationToken, 245 | force?: boolean 246 | ): Promise { 247 | this.handleRunCancelled(token, 'resolveHandler') 248 | 249 | this.log.debug(`VSCode requested resolve for: ${testItem?.id}`) 250 | 251 | // If testitem is undefined, this is a signal to initialize the controller but not actually do anything, so we exit here. 252 | if (testItem === undefined) { 253 | this.log.debug('Received undefined testItem from VSCode, this is a signal to initialize the controller') 254 | return 255 | } 256 | 257 | // Reset any errors previously reported. 258 | testItem.error = undefined 259 | 260 | const testItemData = TestData.get(testItem) 261 | if (!testItemData) { 262 | throw new Error('No matching testItem data found. This is a bug') 263 | } 264 | 265 | // Test Definitions should never show up here, they aren't resolvable in Pester as we only do it at file level 266 | if (isTestItemOptions(testItemData)) { 267 | this.log.error( 268 | `Received a test definition ${testItemData.id} to resolve. Should not happen` 269 | ) 270 | } 271 | 272 | if ( 273 | (testItemData instanceof TestFile && 274 | !testItemData.testsDiscovered && 275 | !testItem.busy) || 276 | (testItemData instanceof TestFile && force) 277 | ) { 278 | // Indicate the start of a discovery, will cause the UI to show a spinner 279 | testItem.busy = true 280 | 281 | // We will use this to compare against the new test view so we can delete any tests that no longer exist 282 | const existingTests = new Set() 283 | await forAll(testItem, item => { 284 | existingTests.add(item) 285 | }, true) 286 | 287 | // Run Pester and get tests 288 | this.log.debug('Adding to Discovery Queue: ', testItem.id) 289 | this.discoveryQueue.add(testItem) 290 | // For discovery we don't care about the terminal output, thats why no assignment to var here 291 | 292 | // TODO: We shouldn't be injecting the newTests set like this but rather have a more functional approach 293 | const newAndChangedTests = new Set() 294 | await this.startTestDiscovery(this.testItemDiscoveryHandler.bind(this, newAndChangedTests)) 295 | 296 | testItem.busy = false 297 | 298 | // If tests were changed that were marked for continuous run, we want to start a run for them 299 | const outdatedTests = new Set() 300 | 301 | 302 | //Get all children of the standing continuous run tests so that we make sure to run them if they are changed. 303 | const allContinuousRunTests = new Set(this.continuousRunTests) 304 | this.continuousRunTests.forEach(test => 305 | getUniqueTestItems(test.children).forEach( 306 | child => allContinuousRunTests.add(child) 307 | ) 308 | ) 309 | 310 | newAndChangedTests.forEach(test => { 311 | if (allContinuousRunTests.has(test)) { 312 | outdatedTests.add(test) 313 | } 314 | }) 315 | 316 | if (outdatedTests.size > 0) { 317 | this.log.info( 318 | `Continuous run tests changed. Starting a run for ${outdatedTests.size} outdated tests` 319 | ) 320 | 321 | const outdatedTestRunRequest = new TestRunRequest( 322 | Array.from(outdatedTests), 323 | undefined, 324 | this.runProfile //TODO: Implement option to use debug profile instead 325 | ) 326 | 327 | this.testHandler(outdatedTestRunRequest) 328 | } 329 | 330 | 331 | } else { 332 | this.log.warn( 333 | `Resolve requested for ${testItem.label} requested but it is already resolving/resolved. Skipping...` 334 | ) 335 | } 336 | } 337 | 338 | /** Called when the refresh button is pressed in vscode. Should clear the handler and restart */ 339 | private refreshHandler() { 340 | this.log.info("VSCode requested a refresh. Re-initializing the Pester Tests extension") 341 | this.stopPowerShell() 342 | clear(this.testController.items) 343 | this.testFileWatchers.forEach(watcher => { 344 | watcher.dispose() 345 | this.disposables.splice(this.disposables.indexOf(watcher), 1) 346 | }) 347 | 348 | this.testFileWatchers = new Map() 349 | this.watch() 350 | } 351 | 352 | /** 353 | * Raw test discovery result objects returned from Pester are processed by this function 354 | */ 355 | private testItemDiscoveryHandler(newTestItems: Set, t: TestDefinition) { 356 | // TODO: This should be done before onDidReceiveObject maybe as a handler callback? 357 | const testDef = t 358 | const testItems = this.testController.items 359 | this.log.trace("Received discovery item from PesterInterface: ", t) 360 | // If there was a syntax error, set the error and short circuit the rest 361 | if (testDef.error) { 362 | const existingTest = this.testController.items.get(testDef.id) 363 | if (existingTest) { 364 | existingTest.error = new MarkdownString( 365 | `$(error) ${testDef.error}`, 366 | true 367 | ) 368 | return 369 | } 370 | } 371 | 372 | const duplicateTestItem = Array.from(newTestItems).find(item => item.id == testDef.id) 373 | if (duplicateTestItem !== undefined) { 374 | const duplicateTestItemMessage = `Duplicate test item ${testDef.id} detected. Two Describe/Context/It objects with duplicate names are not supported by the Pester Test Extension. Please rename one of them, use TestCases/ForEach, or move it to a separate Pester test file. The duplicate will be ignored. This includes ForEach and TestCases, you must use a variable (e.g. ) in your test title.` 375 | this.log.error(duplicateTestItemMessage) 376 | window.showErrorMessage(duplicateTestItemMessage, 'OK') 377 | return 378 | } 379 | 380 | const parent = findTestItem(testDef.parent, testItems) 381 | if (parent === undefined) { 382 | this.log.fatal( 383 | `Test Item ${testDef.label} does not have a parent or its parent was not sent by PesterInterface first. This is a bug and should not happen` 384 | ) 385 | throw new Error( 386 | `Test Item ${testDef.label} does not have a parent or its parent was not sent by PesterInterface first. This is a bug and should not happen` 387 | ) 388 | } 389 | 390 | const testItem = findTestItem(testDef.id, testItems) 391 | 392 | if (testItem !== undefined) { 393 | const newTestItemData = testDef 394 | const existingTestItemData = TestData.get(testItem) as TestDefinition 395 | 396 | if (existingTestItemData === undefined) { 397 | this.log.fatal( 398 | `Test Item ${testDef.label} exists but does not have test data. This is a bug and should not happen` 399 | ) 400 | throw new Error( 401 | `Test Item ${testDef.label} exists but does not have test data. This is a bug and should not happen` 402 | ) 403 | } 404 | 405 | if (isDeepStrictEqual(existingTestItemData, newTestItemData)) { 406 | this.log.trace(`Discovery: Test Exists but has not changed. Skipping: ${testDef.id}`) 407 | return 408 | } 409 | 410 | this.log.info(`Discovery: Test Moved Or Changed - ${testDef.id}`) 411 | 412 | // Update the testItem data with the updated data 413 | TestData.set(testItem, testDef) 414 | 415 | // TODO: Deduplicate the below logic with the new item creation logic into a applyTestItemMetadata function or something 416 | 417 | // If the range has changed, update it so the icons are in the correct location 418 | const foundTestRange = getRange(testDef) 419 | if (!(testItem.range?.isEqual(foundTestRange))) { 420 | this.log.debug(`${testDef.id} moved, updating range`) 421 | testItem.range = foundTestRange 422 | } 423 | 424 | // Update tags if changed 425 | if (testDef.tags !== undefined) { 426 | const newTestTags = testDef.tags?.map(tag => { 427 | return new TestTag(tag) 428 | }) 429 | if (!isDeepStrictEqual(newTestTags, testItem.tags)) { 430 | this.log.debug(`New tags detected, updating: ${testDef.id}`) 431 | testItem.tags = newTestTags 432 | testItem.description = testDef.tags.join(', ') 433 | } 434 | 435 | } 436 | 437 | newTestItems.add(testItem) 438 | } else { 439 | this.log.trace(`Creating Test Item in controller: ${testDef.id} uri: ${parent.uri}`) 440 | 441 | const newTestItem = this.testController.createTestItem( 442 | testDef.id, 443 | testDef.label, 444 | parent.uri 445 | ) 446 | newTestItem.range = getRange(testDef) 447 | 448 | if (testDef.tags !== undefined) { 449 | newTestItem.tags = testDef.tags.map(tag => { 450 | this.log.debug(`Adding tag ${tag} to ${newTestItem.label}`) 451 | return new TestTag(tag) 452 | }) 453 | newTestItem.description = testDef.tags.join(', ') 454 | } 455 | 456 | if (testDef.error !== undefined) { 457 | newTestItem.error = testDef.error 458 | } 459 | 460 | TestData.set(newTestItem, testDef) 461 | this.log.debug(`Adding ${newTestItem.label} to ${parent.label}`) 462 | parent.children.add(newTestItem) 463 | newTestItems.add(newTestItem) 464 | } 465 | } 466 | 467 | /** Used to debounce multiple requests for test discovery at the same time to not overload the pester adapter */ 468 | private startTestDiscovery = debounce(async testItemDiscoveryHandler => { 469 | this.log.info(`Test Discovery Start: ${this.discoveryQueue.size} files`) 470 | let result: void 471 | try { 472 | result = await this.startPesterInterface( 473 | Array.from(this.discoveryQueue), 474 | // TODO: Type this 475 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 476 | testItemDiscoveryHandler as any, 477 | true, 478 | false 479 | ) 480 | } catch (err) { 481 | if (err instanceof PowerShellError) { 482 | const errMessage = 'Test Discovery failed: ' + err.message 483 | window.showErrorMessage(errMessage, 'OK') 484 | this.log.fatal(errMessage) 485 | } 486 | } 487 | this.discoveryQueue.clear() 488 | return result 489 | }, workspace.getConfiguration('pester', this.workspaceFolder).get('testChangeTimeout') ?? 100) 490 | 491 | /** The test controller API calls this when tests are requested to run in the UI. It handles both runs and debugging. 492 | * @param cancelToken The cancellation token passed by VSCode 493 | */ 494 | private async testHandler(request: TestRunRequest, cancelToken?: CancellationToken) { 495 | 496 | if (request.continuous) { 497 | // Add each item in the request include to the continuous run list 498 | this.log.info(`Continuous run enabled for ${request.include?.map(i => i.id)}`) 499 | request.include?.forEach(testItem => { 500 | this.continuousRunTests.add(testItem) 501 | }) 502 | 503 | /** This cancel will be called when the autorun button is disabled */ 504 | const disableContinuousRunToken = cancelToken 505 | 506 | disableContinuousRunToken?.onCancellationRequested(() => { 507 | this.log.info(`Continuous run was disabled for ${request.include?.map(i => i.id)}`) 508 | request.include?.forEach(testItem => { 509 | this.continuousRunTests.delete(testItem) 510 | }) 511 | }) 512 | 513 | // Stop here, we don't actually run tests until discovered or refreshed, at which point continuous flag will not be present. 514 | return 515 | } 516 | 517 | // FIXME: This is just a placeholder to notify that a cancel was requested but it should actually do something. 518 | cancelToken?.onCancellationRequested(() => { 519 | this.log.warn(`RunRequest cancel initiated for ${request.include?.map(i => i.id)}`) 520 | }) 521 | 522 | if (request.profile === undefined) { 523 | throw new Error('No profile provided. This is (currently) a bug.') 524 | } 525 | 526 | 527 | this.log.trace(`VSCode requested ${request.profile.label} [${TestRunProfileKind[request.profile.kind]}] for: `, request.include?.map(i => i.id)) 528 | 529 | const isDebug = request.profile.kind === TestRunProfileKind.Debug 530 | // If nothing was included, assume it means "run all tests" 531 | const include = request.include ?? getTestItems(this.testController.items) 532 | 533 | const run = this.testController.createTestRun(request) 534 | 535 | // Will stop the run and reset the powershell process if the user cancels it 536 | this.handleRunCancelled(run.token, 'TestRun', run) 537 | 538 | // TODO: Make this cleaner and replace getRunRequestTestItems 539 | // If there are no excludes we don't need to do any fancy exclusion test filtering 540 | const testItems = 541 | request.exclude === undefined || request.exclude.length === 0 542 | ? include 543 | : Array.from(this.getRunRequestTestItems(request)) 544 | 545 | // Indicate that the tests are ready to run 546 | // Only mark actual tests as enqueued for better UI: https://github.com/microsoft/vscode-discussions/discussions/672 547 | for (const testItem of testItems) { 548 | forAll(testItem, item => { 549 | const testItemData = TestData.get(item) 550 | if (!testItemData) { 551 | this.log.error(`Test Item Data not found for ${testItem.id}, this should not happen`) 552 | return 553 | } 554 | if (isTestItemOptions(testItemData)) { 555 | if (testItemData.type === 'Test') { 556 | run.enqueued(item) 557 | } 558 | } 559 | }, true) 560 | } 561 | 562 | /** Takes the returned objects from Pester and resolves their status in the test controller **/ 563 | const runResultHandler = (item: unknown) => { 564 | this.log.trace("Received run result from PesterInterface: ", item); 565 | const testResult = item as TestResult 566 | // Skip non-errored Test Suites for now, focus on test results 567 | if (testResult.type === 'Block' && !testResult.error) { 568 | return 569 | } 570 | 571 | const testRequestItem = findTestItem( 572 | testResult.id, 573 | this.testController.items 574 | ) 575 | 576 | if (testRequestItem === undefined) { 577 | this.log.error( 578 | `${testResult.id} was returned from Pester but was not tracked in the test controller. This is probably a bug in test discovery.` 579 | ) 580 | return 581 | } 582 | if (testResult.type === 'Block' && testResult.error !== undefined) { 583 | run.errored( 584 | testRequestItem, 585 | new TestMessage(testResult.error), 586 | testResult.duration 587 | ) 588 | forAll(testRequestItem, run.skipped, true) 589 | return 590 | } 591 | const exclude = new Set(request.exclude) 592 | if (exclude.has(testRequestItem)) { 593 | this.log.warn(`${testResult.id} was run in Pester but excluded from results`) 594 | return 595 | } 596 | if (testResult.result === "Running") { 597 | run.started(testRequestItem) 598 | return 599 | } 600 | 601 | if (testResult.result === "Passed") { 602 | run.passed(testRequestItem, testResult.duration) 603 | } else { 604 | // TODO: This is clumsy and should be a constructor/method on the TestData type perhaps 605 | const message = 606 | testResult.message && testResult.expected && testResult.actual 607 | ? TestMessage.diff( 608 | testResult.message, 609 | testResult.expected, 610 | testResult.actual 611 | ) 612 | : new TestMessage(testResult.message) 613 | if ( 614 | testResult.targetFile != undefined && 615 | testResult.targetLine != undefined 616 | ) { 617 | message.location = new Location( 618 | Uri.file(testResult.targetFile), 619 | new Position(testResult.targetLine, 0) 620 | ) 621 | } 622 | 623 | if ( 624 | testResult.result === "Skipped" && 625 | testResult.message === 'is skipped' 626 | ) { 627 | return run.skipped(testRequestItem) 628 | } else if ( 629 | testResult.result === "Skipped" && 630 | testResult.message && 631 | this.config.get('hideSkippedBecauseMessages') 632 | ) { 633 | // We use "errored" because there is no "skipped" message support in the vscode UI 634 | return run.errored(testRequestItem, message, testResult.duration) 635 | } else if (testResult.result === "Skipped") { 636 | return run.skipped(testRequestItem) 637 | } 638 | 639 | if (message.message) { 640 | return run.failed(testRequestItem, message, testResult.duration) 641 | } 642 | } 643 | } 644 | 645 | // testItems.forEach(run.started) 646 | // TODO: Adjust testItems parameter to a Set 647 | this.log.info(`Test ${isDebug ? 'Debug' : 'Run'} Start: ${testItems.length} test items`) 648 | await this.startPesterInterface( 649 | Array.from(testItems), 650 | runResultHandler.bind(this), 651 | false, 652 | isDebug, 653 | undefined, 654 | run 655 | ) 656 | } 657 | 658 | /** Runs pester either using the nodejs powershell adapterin the PSIC. Results will be sent via a named pipe and handled as events. If a testRun is supplied, it will update the run information and end it when completed. 659 | * Returns a promise that completes with the terminal output during the pester run 660 | * returnHandler will run on each object that comes back from the Pester Interface 661 | */ 662 | // TODO: Mutex or otherwise await so that this can only happen one at a time? 663 | private async startPesterInterface( 664 | testItems: TestItem[], 665 | returnHandler: (event: unknown) => void, 666 | discovery?: boolean, 667 | debug?: boolean, 668 | usePSExtension?: boolean, 669 | testRun?: TestRun 670 | ): Promise { 671 | if (!discovery) { 672 | // HACK: Using flatMap to filter out undefined in a type-safe way. Unintuitive but effective 673 | // https://stackoverflow.com/a/64480539/5511129 674 | // Change to map and filter when https://github.com/microsoft/TypeScript/issues/16069 is resolved 675 | const undiscoveredTestFiles: Promise[] = testItems.flatMap( 676 | testItem => { 677 | const testDataItem = TestData.get(testItem) 678 | if ( 679 | testDataItem instanceof TestFile && 680 | !testDataItem.testsDiscovered 681 | ) { 682 | this.log.debug( 683 | `Run invoked on undiscovered testFile ${testItem.label}, discovery will be run first` 684 | ) 685 | return [this.resolveHandler(testItem)] 686 | } else { 687 | return [] 688 | } 689 | } 690 | ) 691 | // The resolve handler is debounced, this will wait until the delayed resolve handler completes 692 | await Promise.all(undiscoveredTestFiles) 693 | } 694 | 695 | // Debug should always use PSIC for now, so if it is not explicity set, use it 696 | usePSExtension ??= debug 697 | 698 | // Derive Pester-friendly test line identifiers from the testItem info 699 | const testsToRun = testItems.map(testItem => { 700 | if (!testItem.uri) { 701 | throw new Error( 702 | 'TestItem did not have a URI. For pester, this is a bug' 703 | ) 704 | } 705 | const fsPath = testItem.uri.fsPath 706 | const testLine = testItem.range?.start.line 707 | ? [fsPath, testItem.range.start.line + 1].join(':') 708 | : fsPath 709 | return testLine 710 | }) 711 | 712 | const scriptFolderPath = join( 713 | this.context.extension.extensionPath, 714 | 'Scripts' 715 | ) 716 | const scriptPath = join(scriptFolderPath, 'PesterInterface.ps1') 717 | const scriptArgs = new Array() 718 | 719 | if (discovery) { 720 | scriptArgs.push('-Discovery') 721 | } 722 | 723 | // Quotes are required when passing to integrated terminal if the test path has spaces 724 | scriptArgs.push( 725 | ...testsToRun.map(testFilePath => { 726 | return `'${testFilePath}'` 727 | }) 728 | ) 729 | 730 | const pesterSettings = this.config 731 | let verbosity = debug 732 | ? pesterSettings.get('debugOutputVerbosity') 733 | : pesterSettings.get('outputVerbosity') 734 | 735 | if (verbosity === 'FromPreference') { 736 | verbosity = undefined 737 | } 738 | if (verbosity) { 739 | scriptArgs.push('-Verbosity') 740 | scriptArgs.push(verbosity) 741 | } 742 | 743 | const pesterCustomModulePath = this.getPesterCustomModulePath() 744 | if (pesterCustomModulePath !== undefined) { 745 | scriptArgs.push('-CustomModulePath') 746 | scriptArgs.push(pesterCustomModulePath) 747 | } 748 | 749 | const configurationPath = this.config.get('configurationPath') 750 | if (configurationPath !== undefined && configurationPath !== '') { 751 | scriptArgs.push('-ConfigurationPath') 752 | scriptArgs.push(configurationPath) 753 | } 754 | 755 | // Initialize the PSIC if we are using it 756 | if (usePSExtension) { 757 | // HACK: Calling this function indirectly starts/waits for PS Extension to be available 758 | await (await this.powerShellExtensionClientPromise).GetVersionDetails() 759 | } 760 | 761 | // If PSIC is running, we will connect the PowershellExtensionClient to be able to fetch info about it 762 | const psExtensionTerminalLoaded = window.terminals.find( 763 | t => t.name === 'PowerShell Extension' 764 | ) 765 | if (!psExtensionTerminalLoaded) { 766 | this.log.fatal('PowerShell Extension Terminal should be started but was not found in VSCode. This is a bug') 767 | } 768 | 769 | const exePath = psExtensionTerminalLoaded 770 | ? (await (await this.powerShellExtensionClientPromise).GetVersionDetails()).exePath 771 | : undefined 772 | 773 | const cwd = this.getPesterWorkingDirectory() 774 | 775 | // Restart PS to use the requested version if it is different from the current one 776 | if ( 777 | this.ps === undefined || 778 | this.ps.exePath !== exePath || 779 | this.ps.cwd !== cwd 780 | ) { 781 | if (this.ps !== undefined) { 782 | this.log.warn( 783 | `Detected PowerShell Session change from ${this.ps.exePath} to ${exePath}. Restarting Pester Runner.` 784 | ) 785 | this.ps.reset() 786 | } 787 | const exePathDir = exePath 788 | ? dirname(exePath) 789 | : '*DEFAULT POWERSHELL PATH*' 790 | this.log.debug( 791 | `Starting PowerShell Pester testing instance ${exePath} with working directory ${ 792 | cwd ? cwd : exePathDir 793 | }` 794 | ) 795 | this.ps = new PowerShell(exePath, cwd) 796 | } 797 | 798 | // Objects from the run will return to the success stream, which we then send to the return handler 799 | const psOutput = new PSOutput() 800 | psOutput.verbose.on('data', (message: string) => { 801 | this.log.info(`PesterInterface Verbose: ${message}`) 802 | }) 803 | psOutput.debug.on('data', (message: string) => { 804 | this.log.debug(`PesterInterface Debug: ${message}`) 805 | }) 806 | psOutput.warning.on('data', (message: string) => { 807 | this.log.warn(`PesterInterface Warning: ${message}`) 808 | }) 809 | 810 | psOutput.success.on('data', returnHandler) 811 | psOutput.success.once('close', ((testRun: TestRun | undefined) => { 812 | if (testRun) { 813 | this.log.info(`Test Run End: PesterInterface stream closed`) 814 | this.testRunStatus.set(testRun, true) 815 | testRun.end() 816 | } else { 817 | this.log.info(`Discovery Run End (PesterInterface stream closed)`) 818 | } 819 | 820 | this.log.trace(`Removing returnHandler from PSOutput`) 821 | psOutput.success.removeListener('data', returnHandler) 822 | }).bind(this, testRun)) 823 | psOutput.error.on('data', err => { 824 | window.showErrorMessage(`An error occured running Pester: ${err}`, 'OK') 825 | this.log.error(`PesterInterface Error: ${err}`) 826 | if (testRun) { 827 | this.testRunStatus.set(testRun, false) 828 | testRun.end() 829 | } 830 | }) 831 | 832 | if (usePSExtension) { 833 | this.log.debug('Running Script in PSIC:', scriptPath, scriptArgs) 834 | const psListenerPromise = this.returnServer.waitForConnection() 835 | 836 | /** Handles situation where the debug adapter is stopped (usually due to user cancel) before the script completes. */ 837 | const endSocketAtDebugTerminate = (testRun: TestRun | undefined) => { 838 | psListenerPromise.then(socket => socket.end()) 839 | if (testRun && this.testRunStatus.get(testRun) === false) { 840 | this.log.warn("Test run ended due to abrupt debug session end such as the user cancelling the debug session.") 841 | testRun.end() 842 | } 843 | } 844 | 845 | scriptArgs.push('-PipeName') 846 | scriptArgs.push(this.returnServer.name) 847 | // TODO: Fix non-null assertion 848 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 849 | const powershellExtensionClient = await this.powerShellExtensionClientPromise 850 | await powershellExtensionClient.RunCommand( 851 | scriptPath, 852 | scriptArgs, 853 | endSocketAtDebugTerminate.bind(this, testRun), 854 | this.workspaceFolder.uri.fsPath 855 | ) 856 | await this.ps.listen(psOutput, await psListenerPromise) 857 | } else { 858 | const script = `& '${scriptPath}' ${scriptArgs.join(' ')}` 859 | this.log.debug('Running Script in PS Worker:', script) 860 | if (testRun) { 861 | psOutput.information.on('data', (data: string) => { 862 | testRun.appendOutput(data.trimEnd() + '\r\n') 863 | }) 864 | } 865 | const useNewProcess = this.config.get('runTestsInNewProcess') 866 | await this.ps.run(script, psOutput, undefined, true, useNewProcess) 867 | } 868 | } 869 | // Fetches the current working directory that Pester should use. 870 | getPesterWorkingDirectory() { 871 | const customCwd = this.config.get('workingDirectory') 872 | return customCwd ?? this.workspaceFolder.uri.fsPath 873 | } 874 | 875 | /** Fetches the current pester module path if a custom path was defined, otherwise returns undefined */ 876 | getPesterCustomModulePath() { 877 | const path = this.config.get('pesterModulePath') 878 | 879 | // Matches both an empty string and undefined 880 | if (!path) { 881 | return undefined 882 | } 883 | 884 | this.log.info(`Using Custom Pester Module Path specified in settings: ${path}`) 885 | 886 | if (isAbsolute(path)) { 887 | return path 888 | } 889 | // If we make it this far, it's a relative path and we need to resolve that. 890 | if (workspace.workspaceFolders === undefined) { 891 | throw new Error( 892 | `A relative Pester custom module path "${path}" was defined, but no workspace folders were found in the current session. You probably set this as a user setting and meant to set it as a workspace setting` 893 | ) 894 | } 895 | // TODO: Multi-workspace detection and support 896 | const resolvedPath = join(workspace.workspaceFolders[0].uri.fsPath, path) 897 | this.log.debug(`Resolved Pester CustomModulePath ${path} to ${resolvedPath}`) 898 | return resolvedPath 899 | } 900 | 901 | /** Triggered whenever new tests are discovered as the result of a document change */ 902 | private refreshTests(changedFile: Uri) { 903 | const testFile = TestFile.getOrCreate(this.testController, changedFile) 904 | this.resolveHandler(testFile) 905 | } 906 | 907 | /** Find a TestItem by its ID in the TestItem tree hierarchy of this controller */ 908 | // TODO: Maybe build a lookup cache that is populated as items are added 909 | getTestItemById(id: string) { 910 | this.testController.items.get(id) 911 | } 912 | 913 | /** Retrieves all test items to run, minus the exclusions */ 914 | getRunRequestTestItems(request: TestRunRequest) { 915 | // Pester doesn't understand a "root" test so get all files registered to the controller instead 916 | // TODO: Move some of this logic to the TestItemUtils 917 | const tcItems = new Set() 918 | this.testController.items.forEach(item => tcItems.add(item)) 919 | 920 | // TODO: Figure out a way to this without having to build tcItems ahead of time 921 | const testItems = 922 | request.include === undefined 923 | ? tcItems 924 | : new Set(request.include) 925 | 926 | if (request.exclude?.length) { 927 | window.showWarningMessage( 928 | 'Pester: Hiding tests is currently not supported. The tests will still be run but their status will be suppressed' 929 | ) 930 | } 931 | 932 | const exclude = new Set(request.exclude) 933 | 934 | /** Resursively walk the function and add to testitems **/ 935 | const addChildren = (item: TestItem) => { 936 | item.children.forEach(child => { 937 | if (!exclude.has(child)) { 938 | testItems.add(child) 939 | } 940 | addChildren(child) 941 | }) 942 | } 943 | testItems.forEach(addChildren) 944 | return testItems 945 | } 946 | 947 | /** stops the PowerShell Pester instance, it is expected another function will reinitialize it if needed. This function returns false if there was no instance to stop, and returns true otherwise */ 948 | stopPowerShell(cancel?: boolean): boolean { 949 | if (this.ps !== undefined) { 950 | return cancel ? this.ps.cancel() : this.ps.reset() 951 | } 952 | return false 953 | } 954 | 955 | dispose() { 956 | this.log.info(`Disposing Pester Test Controller ${this.label}`) 957 | this.testController.dispose() 958 | this.returnServer.dispose() 959 | this.disposables.forEach(d => d.dispose()) 960 | } 961 | 962 | /** Binds a disposable to this test controller so that it is disposed when the controller is disposed */ 963 | private registerDisposable(...disposable: Disposable[]) { 964 | this.disposables.push(...disposable) 965 | } 966 | 967 | /** Registers to handle cancellation events. This mostly exists to hide the bind function and make the code easier to read */ 968 | private handleRunCancelled(token?: CancellationToken, source?: string, testRun?: TestRun) { 969 | token?.onCancellationRequested( 970 | this.cancelRun.bind(this, source ?? 'Unspecified', testRun) 971 | ) 972 | } 973 | 974 | //** This function will gracefully cancel the current pester process */ 975 | private cancelRun(source: string, testRun?: TestRun | undefined) { 976 | this.log.warn(`${source} Cancellation Detected`) 977 | this.log.warn(`Cancelling PowerShell Process`) 978 | this.stopPowerShell(true) 979 | if (testRun !== undefined) { 980 | this.log.warn(`Cancelling ${testRun?.name ?? 'Unnamed'} Test Run`) 981 | testRun.appendOutput(`\r\nTest Run was cancelled by user from VSCode\r\n`) 982 | testRun.end() 983 | } 984 | this.log.warn(`Test Run Cancelled`) 985 | } 986 | } 987 | 988 | -------------------------------------------------------------------------------- /src/pesterTestTree.ts: -------------------------------------------------------------------------------- 1 | /** Represents a test result returned from pester, serialized into JSON */ 2 | 3 | import { Range, TestController, TestItem, Uri } from 'vscode' 4 | import log from './log' 5 | 6 | /** Represents all types that are allowed to be present in a test tree. This can be a single type or a combination of 7 | * types and organization types such as suites 8 | */ 9 | export type TestTree = TestFile | TestDefinition 10 | 11 | /** An association of test classes to their managed TestItem equivalents. Use this for custom data/metadata about a test 12 | * because we cannot store it in the managed objects we get from the Test API 13 | */ 14 | export const TestData = new WeakMap() 15 | 16 | /** 17 | * Possible states of tests in a test run. 18 | */ 19 | export type TestResultState = string 20 | /** Represents an individual Pester .tests.ps1 file, or an active document in the editor. This is just a stub to be used 21 | * for type identification later, the real work is done in {@link PesterTestController.getOrCreateFile()} 22 | */ 23 | export class TestFile { 24 | // Indicates if a testfile has had Pester Discovery run at least once 25 | testsDiscovered = false 26 | private constructor( 27 | private readonly controller: TestController, 28 | private readonly uri: Uri 29 | ) {} 30 | get file() { 31 | return this.uri.fsPath 32 | } 33 | get startLine() { 34 | return undefined 35 | } 36 | get testItem() { 37 | const testItem = this.controller.items.get(this.uri.toString()) 38 | if (!testItem) { 39 | throw new Error( 40 | 'No associated test item for testfile:' + this.uri + '. This is a bug.' 41 | ) 42 | } 43 | return testItem 44 | } 45 | 46 | /** Creates a managed TestItem entry in the controller if it doesn't exist, or returns the existing object if it does already exist */ 47 | static getOrCreate(controller: TestController, uri: Uri): TestItem { 48 | // Normalize paths to uppercase on windows due to formatting differences between Javascript and PowerShell 49 | const uriFsPath = 50 | process.platform === 'win32' ? uri.fsPath.toUpperCase() : uri.fsPath 51 | const existing = controller.items.get(uriFsPath) 52 | if (existing) { 53 | return existing 54 | } 55 | log.trace('Creating test item for file: ' + uriFsPath) 56 | const fileTestItem = controller.createTestItem( 57 | uriFsPath, 58 | // TODO: Fix non-null assertion 59 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 60 | uri.path.split('/').pop()!, 61 | uri 62 | ) 63 | TestData.set(fileTestItem, new TestFile(controller, uri)) 64 | fileTestItem.canResolveChildren = true 65 | controller.items.add(fileTestItem) 66 | return fileTestItem 67 | } 68 | } 69 | 70 | /** 71 | * Options for calling the createTestItem function This is the minimum required for createTestItem. 72 | * @template TParent - What types this TestItem is allowed to have as a parent. TestFile should always have the controller root as a parent 73 | * @template TChild - What types this TestItem can have as a child. Leaf TestItems like test cases should specify 'never' 74 | */ 75 | export interface TestItemOptions { 76 | /** Uniquely identifies the test. Can be anything but must be unique to the controller */ 77 | id: string 78 | /** A label for the testItem. This is how it will appear in the test, explorer pane */ 79 | label: string 80 | /** Which test item is the parent of this item. You can specify the test controller root here */ 81 | parent: string 82 | /** A resource URI that matches the physical location of this test */ 83 | uri?: Uri 84 | /** TODO: A temporary type hint until I do a better serialization method */ 85 | type?: string 86 | } 87 | 88 | /** Represents a test that has been discovered by Pester. TODO: Separate suite definition maybe? */ 89 | export interface TestDefinition extends TestItemOptions { 90 | startLine: number 91 | endLine: number 92 | file: string 93 | description?: string 94 | error?: string 95 | tags?: string[] 96 | scriptBlock?: string 97 | } 98 | 99 | /** The type used to represent a test run from the Pester runner, with additional status data */ 100 | export interface TestResult extends TestItemOptions { 101 | result: TestResultState 102 | error: string 103 | duration: number 104 | durationDetail: string 105 | message: string 106 | expected: string 107 | actual: string 108 | targetFile: string 109 | targetLine: number 110 | description?: string 111 | } 112 | 113 | /** Given a testdefinition, fetch the vscode range */ 114 | export function getRange(testDef: TestDefinition): Range { 115 | return new Range(testDef.startLine, 0, testDef.endLine, 0) 116 | } 117 | -------------------------------------------------------------------------------- /src/powershell.test.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process' 2 | import ReadlineTransform from 'readline-transform' 3 | import { Readable } from 'stream' 4 | import { pipeline } from 'stream/promises' 5 | import { 6 | createJsonParseTransform, 7 | PowerShell, 8 | PSOutput, 9 | defaultPowershellExePath, 10 | PowerShellError 11 | } from './powershell' 12 | import { expect } from 'chai' 13 | 14 | describe('jsonParseTransform', () => { 15 | interface TestObject { 16 | Test: number 17 | } 18 | 19 | it('object', async () => { 20 | const source = Readable.from(['{"Test": 5}']) 21 | const jsonPipe = createJsonParseTransform() 22 | await pipeline(source, jsonPipe) 23 | const result = jsonPipe.read() as TestObject 24 | expect(result.Test).to.equal(5) 25 | }) 26 | 27 | it('empty', async () => { 28 | const source = Readable.from(['']).pipe( 29 | new ReadlineTransform({ skipEmpty: false }) 30 | ) 31 | const jsonPipe = createJsonParseTransform() 32 | 33 | try { 34 | await pipeline(source, jsonPipe) 35 | } catch (err) { 36 | const result = err as Error 37 | expect(result.message).to.match(/Unexpected end/) 38 | } 39 | }) 40 | 41 | it('syntaxError', async () => { 42 | const source = Readable.from(['"Test":5}']).pipe( 43 | new ReadlineTransform({ skipEmpty: false }) 44 | ) 45 | const jsonPipe = createJsonParseTransform() 46 | 47 | try { 48 | await pipeline(source, jsonPipe) 49 | } catch (err) { 50 | const result = err as Error 51 | expect(result.message).to.match(/Unexpected token :/) 52 | } 53 | }) 54 | }) 55 | 56 | describe('run', function () { 57 | this.slow(2500) 58 | let ps: PowerShell 59 | beforeEach(() => { 60 | ps = new PowerShell() 61 | }) 62 | afterEach(() => { 63 | ps.dispose() 64 | }) 65 | it('finished', async () => { 66 | const streams = new PSOutput() 67 | await ps.run(`'JEST'`, streams) 68 | // This test times out if it doesn't execute successfully 69 | }) 70 | it('success', done => { 71 | const streams = new PSOutput() 72 | streams.success.on('data', data => { 73 | expect(data).to.equal('JEST') 74 | done() 75 | }) 76 | void ps.run(`'JEST'`, streams) 77 | }) 78 | 79 | it('verbose', done => { 80 | const streams = new PSOutput() 81 | streams.verbose.on('data', data => { 82 | expect(data).to.equal('JEST') 83 | done() 84 | }) 85 | void ps.run(`Write-Verbose -verbose 'JEST'`, streams) 86 | }) 87 | 88 | it('mixed', async () => { 89 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 90 | const successResult: any[] = [] 91 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 92 | const infoResult: any[] = [] 93 | const streams = new PSOutput() 94 | streams.success 95 | .on('data', data => { 96 | successResult.push(data) 97 | }) 98 | .on('close', () => { 99 | expect(successResult[0]).to.equal('JEST') 100 | }) 101 | streams.information 102 | .on('data', data => { 103 | infoResult.push(data) 104 | }) 105 | .on('close', () => { 106 | expect(infoResult.length).to.equal(32) 107 | }) 108 | streams.error.on('data', data => { 109 | expect(data).to.equal('oops!') 110 | }) 111 | 112 | await ps.run(`1..32 | Write-Host;Write-Error 'oops!';'JEST';1..2`, streams) 113 | }) 114 | }) 115 | 116 | describe('exec', function () { 117 | this.slow(2500) 118 | let ps: PowerShell 119 | beforeEach(() => { 120 | ps = new PowerShell() 121 | }) 122 | afterEach(() => { 123 | ps.dispose() 124 | }) 125 | 126 | it('Get-Item', async () => { 127 | const result = await ps.exec(`Get-Item .`) 128 | 129 | expect(result[0].PSIsContainer).to.be.true 130 | }) 131 | 132 | /** Verify that if two commands are run at the same time, they queue and complete independently without interfering with each other */ 133 | it('Parallel', async () => { 134 | const result = ps.exec(`'Item1';sleep 0.05`) 135 | const result2 = ps.exec(`'Item2'`) 136 | 137 | expect((await result2)[0]).to.equal('Item2') 138 | expect((await result)[0]).to.equal('Item1') 139 | }) 140 | 141 | /** Verify that a terminating error is emitted within the context of an exec */ 142 | it('TerminatingError', async () => { 143 | try { 144 | await ps.exec(`throw 'oops!'`) 145 | } catch (err) { 146 | expect(err).to.be.instanceOf(PowerShellError) 147 | } 148 | }) 149 | 150 | /** If cancelExisting is used, ensure the first is closed quickly */ 151 | it('CancelExisting', async () => { 152 | const result = ps.exec(`'Item';sleep 5;'ThisItemShouldNotEmit'`, true) 153 | // FIXME: This is a race condition on slower machines that makes this test fail intermittently 154 | // If Item hasn't been emitted yet from the pipeline 155 | // This should instead watch for Item and then cancel existing once received 156 | await new Promise(resolve => setTimeout(resolve, 600)) 157 | const result2 = ps.exec(`'Item'`, true) 158 | const awaitedResult = await result 159 | const awaitedResult2 = await result2 160 | 161 | // Any existing results should still be emitted after cancellation 162 | expect(awaitedResult).to.be.an('array').that.includes('Item') 163 | expect(awaitedResult2).to.be.an('array').that.includes('Item') 164 | }) 165 | 166 | it('pwsh baseline', () => { 167 | const result = execSync(`${defaultPowershellExePath} -nop -c "echo hello"`) 168 | 169 | expect(result.toString()).to.match(/hello/) 170 | }) 171 | 172 | // TODO: Add a hook so that a cancel can be run without this test being performance-dependent. Currently this test is flaky depending on how fast the machine is 173 | it('cancel', async () => { 174 | const result = ps.exec(`'Item1','Item2';sleep 2;'Item3'`) 175 | await new Promise(resolve => setTimeout(resolve, 1000)) 176 | ps.cancel() 177 | const awaitedResult = await result 178 | 179 | expect(awaitedResult) 180 | .to.be.an('array') 181 | .that.includes('Item1') 182 | .and.includes('Item2') 183 | .but.does.not.include('Item3') 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /src/powershell.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams, spawn } from 'child_process' 2 | import { lookpath } from 'lookpath' 3 | import { resolve } from 'path' 4 | import { Readable, Transform, Writable } from 'stream' 5 | import { pipeline, finished } from 'stream/promises' 6 | import ReadlineTransform from 'readline-transform' 7 | import createStripAnsiTransform from './stripAnsiStream' 8 | import { homedir } from 'os' 9 | import jsonParseSafe from 'json-parse-safe' 10 | 11 | /** Streams for PowerShell Output: https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_output_streams?view=powershell-7.1 12 | * 13 | * You can either extend this interface and use custom streams to handle the incoming objects, or use the default 14 | * implementation and subscribe to data events on the streams 15 | */ 16 | export interface IPSOutput { 17 | success: Readable 18 | error: Readable 19 | warning: Readable 20 | verbose: Readable 21 | debug: Readable 22 | information: Readable 23 | progress: Readable 24 | } 25 | 26 | /** Includes an object of the full PowerShell error */ 27 | export class PowerShellError extends Error { 28 | // TODO: Strong type this 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 30 | constructor(message: string, public error: any) { 31 | const errorDetail = (typeof error === 'string') 32 | ? error 33 | : `${error.Exception.Message} ${error.ScriptStackTrace}` 34 | 35 | super(`${message}: ${errorDetail}`) 36 | } 37 | } 38 | 39 | 40 | /** A simple Readable that emits events when new objects are pushed from powershell. 41 | * read() does nothing and generally should not be called, you should subscribe to the events instead 42 | */ 43 | export function createPSReadableStream() { 44 | return new Readable({ 45 | objectMode: true, 46 | read() { 47 | return 48 | } 49 | }) 50 | } 51 | 52 | export class PSOutput implements IPSOutput { 53 | constructor( 54 | public success: Readable = createPSReadableStream(), 55 | public error: Readable = createPSReadableStream(), 56 | public warning: Readable = createPSReadableStream(), 57 | public verbose: Readable = createPSReadableStream(), 58 | public debug: Readable = createPSReadableStream(), 59 | public information: Readable = createPSReadableStream(), 60 | public progress: Readable = createPSReadableStream() 61 | ) {} 62 | } 63 | 64 | /** An implementation of IPSOutput that takes all result objects and collects them to a single stream */ 65 | export class PSOutputUnified implements IPSOutput { 66 | constructor( 67 | public success: Readable = createPSReadableStream(), 68 | public error: Readable = success, 69 | public warning: Readable = success, 70 | public verbose: Readable = success, 71 | public debug: Readable = success, 72 | public information: Readable = success, 73 | public progress: Readable = success 74 | ) {} 75 | read() { 76 | return this.success.read() as T 77 | } 78 | } 79 | 80 | /** Takes JSON string from the input stream and generates objects. Is exported for testing purposes */ 81 | export function createJsonParseTransform() { 82 | return new Transform({ 83 | objectMode: true, 84 | transform(chunk: string, _encoding: string, next) { 85 | const jsonResult = jsonParseSafe(chunk) 86 | // Check if jsonResult is the non exported type OutputError 87 | if ('error' in jsonResult) { 88 | jsonResult.error.message = `${jsonResult.error.message} \r\nJSON: ${chunk}` 89 | next(jsonResult.error) 90 | } else { 91 | next(undefined, jsonResult.value) 92 | } 93 | } 94 | }) 95 | } 96 | 97 | /** Awaits the special finshed message object and ends the provided stream, which will gracefully end the upstream pipeline after all 98 | * objects are processed. 99 | * We have to gracefully end the upstream pipeline so as not to generate errors. If we do this.end() it wont 100 | * work because the upstream pipe is still open. If we do this.destroy() it wont work without handling an error 101 | * And the pipeline promise will not resolve. 102 | * More: https://nodejs.org/es/docs/guides/backpressuring-in-streams/#lifecycle-of-pipe 103 | * */ 104 | function createWatchForScriptFinishedMessageTransform(streamToEnd: Writable) { 105 | return new Transform({ 106 | objectMode: true, 107 | // TODO: Strong type this 108 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 109 | transform(chunk: any, _encoding: string, next) { 110 | // If special message from PowerShell Invocation Script 111 | // TODO: Handle this as a class? 112 | if (chunk.__PSINVOCATIONID && chunk.finished === true) { 113 | streamToEnd.end() 114 | next() 115 | } else { 116 | next(undefined, chunk) 117 | } 118 | } 119 | }) 120 | } 121 | 122 | /** takes a unified stream of PS Objects and splits them into their appropriate streams */ 123 | export function createSplitPSOutputStream(streams: IPSOutput) { 124 | return new Writable({ 125 | objectMode: true, 126 | write(chunk, _, next) { 127 | const record = chunk.value ?? chunk 128 | switch (chunk.__PSStream) { 129 | // Unless a stream is explicitly set, the default is to use the success stream 130 | case undefined: 131 | streams.success.push(chunk) 132 | break 133 | case 'Success': 134 | streams.success.push(chunk) 135 | break 136 | case 'Error': 137 | streams.error.push(record) 138 | break 139 | case 'Warning': 140 | streams.warning.push(record) 141 | break 142 | case 'Verbose': 143 | streams.verbose.push(record) 144 | break 145 | case 'Debug': 146 | streams.debug.push(record) 147 | break 148 | case 'Information': 149 | streams.information.push(record) 150 | break 151 | case 'Progress': 152 | streams.progress.push(record) 153 | break 154 | default: 155 | next(new Error(`Unknown PSStream Reported: ${chunk.__PSStream}`)) 156 | } 157 | next() 158 | }, 159 | final(next) { 160 | streams.success.destroy() 161 | streams.error.destroy() 162 | streams.warning.destroy() 163 | streams.verbose.destroy() 164 | streams.debug.destroy() 165 | streams.information.destroy() 166 | streams.progress.destroy() 167 | next() 168 | } 169 | }) 170 | } 171 | 172 | export const defaultPowershellExePath = 173 | process.platform === 'win32' 174 | ? 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' 175 | : 'pwsh' 176 | 177 | /** Represents an instance of a PowerShell process. By default this will use pwsh if installed, and will fall back to PowerShell on Windows, 178 | * unless the exepath parameter is specified. Use the exePath parameter to specify specific powershell executables 179 | * such as pwsh-preview or a pwsh executable not located in the PATH 180 | * @param exePath The path to the powershell executable to use. If not specified, the default will be used. 181 | * @param cwd The current working directory of the process. All paths will be relative to this. Defaults to the folder where pwsh.exe resides. 182 | */ 183 | export class PowerShell { 184 | psProcess: ChildProcessWithoutNullStreams | undefined 185 | private currentInvocation: Promise | undefined 186 | private resolvedExePath: string | undefined 187 | constructor(public exePath?: string, public cwd?: string) {} 188 | 189 | /** lazy-start a pwsh instance. If pwsh is not found but powershell is present, it will silently use that instead. */ 190 | private async initialize() { 191 | if (this.psProcess === undefined) { 192 | const pathToResolve = this.exePath ?? 'pwsh' 193 | const path = await lookpath(pathToResolve) 194 | if (path !== undefined) { 195 | this.resolvedExePath = path 196 | } else if (process.platform === 'win32') { 197 | this.resolvedExePath = 198 | 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' 199 | } else { 200 | throw new Error( 201 | 'pwsh not found in your path and you are not on Windows so PowerShell 5.1 is not an option. Did you install PowerShell first?' 202 | ) 203 | } 204 | const psEnv = process.env 205 | 206 | if (!process.env.HOME) { 207 | // Sometimes this is missing and will screw up PSModulePath detection on Windows/Linux 208 | process.env.HOME = homedir() 209 | } 210 | 211 | // This disables ANSI output in PowerShell so it doesnt "corrupt" the JSON output 212 | //Ref: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_ansi_terminals?view=powershell-7.3#disabling-ansi-output 213 | psEnv.NO_COLOR = '1' 214 | 215 | this.psProcess = spawn( 216 | this.resolvedExePath, 217 | ['-NoProfile', '-NonInteractive', '-NoExit', '-Command', '-'], 218 | { 219 | cwd: this.cwd, 220 | env: psEnv 221 | } 222 | ) 223 | // Warn if we have more than one listener set on a process 224 | this.psProcess.stdout.setMaxListeners(1) 225 | this.psProcess.stderr.setMaxListeners(1) 226 | 227 | if (!this.psProcess.pid) { 228 | throw new Error(`Failed to start PowerShell process.`) 229 | } 230 | } 231 | } 232 | 233 | /** Similar to {@link run} but doesn't execute anything, rather listens on a particular stream for JSON objects to arrive */ 234 | async listen(psOutput: IPSOutput, inputStream?: Readable) { 235 | await this.run('', psOutput, inputStream) 236 | } 237 | 238 | /** Run a PowerShell script asynchronously, result objects will arrive via the provided PSOutput streams 239 | * the returned Promise will complete when the script has finished running 240 | * @param inputStream 241 | * Specify a Readable (such as a named pipe stream) that supplies single-line JSON objects from a PowerShell execution. 242 | * If not specified, it will read stdout from a new powershell process. 243 | * @param script 244 | * The PowerShell script to run 245 | * If script is null then it will simply listen and process objects incoming on the stream until it closes 246 | */ 247 | async run( 248 | script: string, 249 | psOutput: IPSOutput, 250 | inputStream?: Readable, 251 | cancelExisting?: boolean, 252 | useNewProcess?: boolean 253 | ) { 254 | if (useNewProcess) { 255 | this.reset() 256 | } 257 | 258 | // We only run one command at a time for now 259 | // TODO: Use a runspace pool and tag each invocation with a unique ID 260 | if (this.currentInvocation) { 261 | if (cancelExisting) { 262 | this.cancel() 263 | } else await this.currentInvocation 264 | } 265 | 266 | await this.initialize() 267 | if (this.psProcess === undefined) { 268 | throw new Error('PowerShell initialization failed') 269 | } 270 | // If an input stream wasn't specified, use stdout by default. This will be the most common path. 271 | inputStream ??= this.psProcess.stdout 272 | 273 | // FIXME: There should only be one end listener from the readlineTransform pipe, currently there are two, why? 274 | inputStream.setMaxListeners(2) 275 | 276 | // Wire up a listener for terminating errors that will reject a promise we will race with the normal operation 277 | // TODO: RemoveAllListeners should be more specific 278 | this.psProcess.stdout.removeAllListeners() 279 | this.psProcess.stderr.removeAllListeners() 280 | 281 | /** Will emit an error if an error is received on the stderr of the PowerShell process */ 282 | const errorWasEmitted = new Promise((_resolve, reject) => { 283 | // Read error output one line at a time 284 | function handleError(errorAsJsonOrString: string) { 285 | const jsonResult = jsonParseSafe(errorAsJsonOrString) 286 | const error = ("error" in jsonResult) 287 | ? new PowerShellError( 288 | 'An initialization error occured while running the script', 289 | errorAsJsonOrString 290 | ) 291 | : new PowerShellError( 292 | 'A terminating error was received from PowerShell', 293 | jsonResult.value 294 | ) 295 | reject(error) 296 | } 297 | 298 | // Wires up to the error stream 299 | if (this.psProcess !== undefined) { 300 | const errorStream = this.psProcess.stderr.pipe( 301 | new ReadlineTransform({ skipEmpty: false }), 302 | ).pipe( 303 | createStripAnsiTransform() 304 | ) 305 | errorStream.once('data', handleError) 306 | } 307 | }) 308 | 309 | // We dont want inputStream to be part of our promise pipeline because we want it to stay open to be resused 310 | // And the promise won't resolve if it stays open and is part of the pipeline 311 | const readlineTransform = inputStream.pipe( 312 | new ReadlineTransform({ skipEmpty: false }) 313 | ) 314 | 315 | // This is our main input stream processing pipeline where we handle messages from PowerShell 316 | const pipelineCompleted = pipeline( 317 | readlineTransform, 318 | createStripAnsiTransform(), 319 | createJsonParseTransform(), 320 | createWatchForScriptFinishedMessageTransform(readlineTransform), 321 | createSplitPSOutputStream(psOutput) 322 | ) 323 | 324 | const runnerScriptPath = resolve( 325 | __dirname, 326 | '..', 327 | 'Scripts', 328 | 'powershellRunner.ps1' 329 | ) 330 | // Start the script, the output will be processed by the above events 331 | if (script) { 332 | const fullScript = `& '${runnerScriptPath}' {${script}}\n` 333 | this.psProcess.stdin.write(fullScript) 334 | } 335 | 336 | // Either the script completes or a terminating error occured 337 | this.currentInvocation = Promise.race([ 338 | pipelineCompleted, 339 | errorWasEmitted 340 | ]).then(() => { 341 | // Reset the current invocation status 342 | this.currentInvocation = undefined 343 | }) 344 | 345 | // Indicate the result is complete 346 | return this.currentInvocation 347 | } 348 | 349 | /** Runs a script and returns all objects generated by the script. This is a simplified interface to run */ 350 | async exec(script: string, cancelExisting?: boolean) { 351 | const psOutput = new PSOutputUnified() 352 | await this.run(script, psOutput, undefined, cancelExisting) 353 | 354 | if (!psOutput.success.destroyed) { 355 | await finished(psOutput.success) 356 | } 357 | const result: Record[] = [] 358 | for (;;) { 359 | const output = psOutput.success.read() as Record 360 | if (output === null) { 361 | break 362 | } 363 | result.push(output) 364 | } 365 | return result 366 | } 367 | 368 | /** Cancel an existing pipeline in progress by emitting a finished object and then killing the process */ 369 | cancel() { 370 | if (this.psProcess !== undefined) { 371 | this.psProcess?.stdout.push( 372 | '{"__PSINVOCATIONID": "CANCELLED", "finished": true}' 373 | ) 374 | } 375 | return this.reset() 376 | } 377 | 378 | /** Kill any existing invocations and reset the state */ 379 | reset(): boolean { 380 | let result = false 381 | if (this.psProcess !== undefined) { 382 | // We use SIGKILL to keep the behavior consistent between Windows and Linux (die immediately) 383 | this.psProcess.kill('SIGKILL') 384 | result = true 385 | } 386 | // Initialize will reinstate the process upon next call 387 | this.psProcess = undefined 388 | return result 389 | } 390 | 391 | dispose() { 392 | this.reset() 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /src/powershell.types.ts: -------------------------------------------------------------------------------- 1 | /** A base interface that represents objects other than the output stream */ 2 | interface IPowerShellStreamObject { 3 | __PSStream: string 4 | } 5 | -------------------------------------------------------------------------------- /src/powershellExtensionClient.ts: -------------------------------------------------------------------------------- 1 | // Eventually something like this would go in an npm package 2 | 3 | import { randomInt } from 'crypto' 4 | import { 5 | debug, 6 | DebugConfiguration, 7 | DebugSession, 8 | Extension, 9 | ExtensionContext, 10 | extensions, 11 | window, 12 | workspace 13 | } from 'vscode' 14 | 15 | export interface IPowerShellExtensionClient { 16 | registerExternalExtension(id: string, apiVersion?: string): string 17 | unregisterExternalExtension(uuid: string): boolean 18 | getPowerShellVersionDetails(uuid: string): Promise 19 | } 20 | 21 | export class PowerShellExtensionClient { 22 | static async create( 23 | context: ExtensionContext, 24 | powershellExtension: Extension 25 | ) { 26 | const internalPowerShellExtensionClient = 27 | await powershellExtension.activate() 28 | const item = new PowerShellExtensionClient( 29 | context, 30 | internalPowerShellExtensionClient 31 | ) 32 | item.RegisterExtension(item.context.extension.id) 33 | return item 34 | } 35 | 36 | private constructor( 37 | private context: ExtensionContext, 38 | private internalPowerShellExtensionClient: IPowerShellExtensionClient 39 | ) {} 40 | 41 | private _sessionId: string | undefined 42 | private get sessionId(): string | undefined { 43 | if (!this._sessionId) { 44 | throw new Error( 45 | 'Client is not registered. You must run client.RegisterExtension(extensionId) first before using any other APIs.' 46 | ) 47 | } 48 | 49 | return this._sessionId 50 | } 51 | 52 | private set sessionId(id: string | undefined) { 53 | this._sessionId = id 54 | } 55 | 56 | public get IsConnected() { 57 | return this._sessionId != null 58 | } 59 | 60 | /** 61 | * RegisterExtension 62 | * https://github.com/PowerShell/vscode-powershell/blob/2d30df76eec42a600f97f2cc28105a9793c9821b/src/features/ExternalApi.ts#L25-L38 63 | */ 64 | // We do this as part of the constructor so it doesn't have to be public anymore 65 | private RegisterExtension(extensionId: string) { 66 | this.sessionId = 67 | this.internalPowerShellExtensionClient.registerExternalExtension( 68 | extensionId 69 | ) 70 | } 71 | 72 | /** 73 | * UnregisterExtension 74 | * https://github.com/PowerShell/vscode-powershell/blob/2d30df76eec42a600f97f2cc28105a9793c9821b/src/features/ExternalApi.ts#L42-L54 75 | */ 76 | public UnregisterExtension() { 77 | this.internalPowerShellExtensionClient.unregisterExternalExtension( 78 | this.sessionId as string 79 | ) 80 | this.sessionId = undefined 81 | } 82 | 83 | /** 84 | * GetVersionDetails 85 | * https://github.com/PowerShell/vscode-powershell/blob/master/src/features/ExternalApi.ts#L58-L76 86 | */ 87 | public GetVersionDetails(): Thenable { 88 | return this.internalPowerShellExtensionClient.getPowerShellVersionDetails( 89 | this.sessionId as string 90 | ) 91 | } 92 | 93 | /** 94 | * Lazily fetches the current terminal instance of the PowerShell Integrated Console or starts it if not present 95 | */ 96 | public static GetPowerShellIntegratedConsole() { 97 | return window.terminals.find( 98 | t => t.name === 'PowerShell Integrated Console' 99 | ) 100 | } 101 | 102 | public static GetPowerShellSettings() { 103 | return workspace.getConfiguration('powershell') 104 | } 105 | public static GetPesterSettings() { 106 | return workspace.getConfiguration('powershell.pester') 107 | } 108 | 109 | public async RunCommand( 110 | command: string, 111 | args?: string[], 112 | onComplete?: (terminalData: DebugSession) => void, 113 | cwd?: string, 114 | ) { 115 | // This indirectly loads the PSES extension and console 116 | await this.GetVersionDetails() 117 | PowerShellExtensionClient.GetPowerShellIntegratedConsole() 118 | 119 | // RandomUUID is not available in vscode 1.62, this is a simple substitute 120 | // I couldn't find this defined in Javascript/NodeJs anywhere. https://stackoverflow.com/questions/33609404/node-js-how-to-generate-random-numbers-in-specific-range-using-crypto-randomby 121 | const maxRandomNumber = 281474976710655 122 | 123 | const debugId = randomInt(maxRandomNumber) 124 | const debugConfig: DebugConfiguration = { 125 | request: 'launch', 126 | type: 'PowerShell', 127 | name: 'PowerShell Launch Pester Tests', 128 | script: command, 129 | args: args, 130 | // We use the PSIC, not the vscode native debug console 131 | internalConsoleOptions: 'neverOpen', 132 | // TODO: Update this deprecation to match with the paths in the arg? 133 | cwd: cwd, 134 | __Id: debugId 135 | // createTemporaryIntegratedConsole: settings.debugging.createTemporaryIntegratedConsole, 136 | // cwd: 137 | // currentDocument.isUntitled 138 | // ? vscode.workspace.rootPath 139 | // : path.dirname(currentDocument.fileName), 140 | } 141 | 142 | // FIXME: Figure out another way to capture terminal data, this is a proposed API that will never go stable 143 | // const terminalDataEvent = window.onDidWriteTerminalData(e => { 144 | // if (e.terminal !== psic) {return} 145 | // terminalData += e.data 146 | // }) 147 | 148 | const debugStarted = await debug.startDebugging( 149 | debugConfig.cwd, 150 | debugConfig 151 | ) 152 | // HACK: Ideally startDebugging would return the ID of the session 153 | const thisDebugSession = debug.activeDebugSession 154 | if (!debugStarted || !thisDebugSession) { 155 | throw new Error('Debug Session did not start as expected') 156 | } 157 | const stopDebugEvent = debug.onDidTerminateDebugSession(debugSession => { 158 | if (debugSession.configuration.__Id !== debugId) { 159 | return 160 | } 161 | // This is effectively a "once" operation 162 | stopDebugEvent.dispose() 163 | if (onComplete) { 164 | onComplete(debugSession) 165 | } 166 | }) 167 | } 168 | } 169 | 170 | export interface IExternalPowerShellDetails { 171 | exePath: string 172 | version: string 173 | displayName: string 174 | architecture: string 175 | } 176 | 177 | export function waitForPowerShellExtension() { 178 | return new Promise>(resolve => { 179 | const powershellExtension = extensions.getExtension('ms-vscode.PowerShell') 180 | if (powershellExtension) { 181 | return resolve(powershellExtension) 182 | } 183 | 184 | window.showWarningMessage( 185 | 'You must first install or enable the PowerShell or PowerShell Preview extension to ' + 186 | 'use the Pester Test Adapter. It will be activated automatically once the required extension is installed' 187 | ) 188 | // Register an event that watch for the PowerShell Extension to show up 189 | const activatedEvent = extensions.onDidChange(() => { 190 | const powershellExtension = extensions.getExtension('ms-vscode.PowerShell') 191 | if (powershellExtension) { 192 | activatedEvent.dispose() 193 | return resolve(powershellExtension) 194 | } 195 | }) 196 | }) 197 | } 198 | -------------------------------------------------------------------------------- /src/stripAnsiStream.ts: -------------------------------------------------------------------------------- 1 | // Borrowed with love from: https://github.com/chalk/strip-ansi-stream/tree/main because it is ES only 2 | 3 | import stripAnsi from '@ctiterm/strip-ansi' 4 | import { Transform } from 'stream' 5 | 6 | export default function createStripAnsiTransform() { 7 | return new Transform({ 8 | objectMode: true, 9 | transform(chunk: string, encoding: string, done) { 10 | this.push( 11 | stripAnsi(chunk) 12 | ) 13 | done() 14 | } 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/util/testItemUtils.ts: -------------------------------------------------------------------------------- 1 | import { TestItem, TestItemCollection } from 'vscode' 2 | import { TestDefinition, TestTree } from '../pesterTestTree' 3 | 4 | /** Returns a Set of all TestItems and their children recursively in the collection. This assumes all your test IDs are unique, duplicates will be replaced **/ 5 | export function getUniqueTestItems(collection: TestItemCollection) { 6 | const TestItems = new Set() 7 | const addTestItem = (TestItem: TestItem) => { 8 | TestItems.add(TestItem) 9 | TestItem.children.forEach(addTestItem) 10 | } 11 | collection.forEach(addTestItem) 12 | return TestItems 13 | } 14 | 15 | /** Returns an array of TestItems at the root of the TestItemConnection. This does not fetch children */ 16 | export function getTestItems(collection: TestItemCollection) { 17 | const TestItems = new Array() 18 | const addTestItem = (TestItem: TestItem) => { 19 | TestItems.push(TestItem) 20 | } 21 | collection.forEach(addTestItem) 22 | return TestItems 23 | } 24 | 25 | /** Performs a breadth-first search for a test item in a given collection. It assumes your test IDs are unique and will only return the first one it finds **/ 26 | export function findTestItem(id: string, collection: TestItemCollection) { 27 | const queue = new Array(collection) 28 | 29 | let match: TestItem | undefined 30 | while (queue.length) { 31 | const currentCollection = queue.shift() 32 | if (!currentCollection) { return } 33 | currentCollection.forEach(item => { 34 | if (item.id === id) { 35 | match = item 36 | } 37 | if (item.children.size) { 38 | queue.push(item.children) 39 | } 40 | }) 41 | if (match) { 42 | return match 43 | } 44 | } 45 | } 46 | 47 | /** Runs the specified function on this item and all its children, if present */ 48 | export async function forAll( 49 | parent: TestItem, 50 | fn: (child: TestItem) => void, 51 | skipParent?: boolean 52 | ) { 53 | if (!skipParent) { 54 | fn(parent) 55 | } 56 | parent.children.forEach(child => { 57 | forAll(child, fn, false) 58 | }) 59 | } 60 | 61 | /** Removes all items from a test item collection */ 62 | export async function clear(collection: TestItemCollection) { 63 | collection.forEach((item, collection) => collection.delete(item.id)) 64 | } 65 | 66 | 67 | /** Gets the parents of the TestItem */ 68 | export function getParents(TestItem: TestItem) { 69 | const parents = [] 70 | let parent = TestItem.parent 71 | while (parent) { 72 | parents.push(parent) 73 | parent = parent.parent 74 | } 75 | return parents 76 | } 77 | 78 | export function isTestItemOptions(testItem: TestTree): testItem is TestDefinition { 79 | return 'type' in testItem 80 | } 81 | 82 | -------------------------------------------------------------------------------- /src/workspaceWatcher.ts: -------------------------------------------------------------------------------- 1 | import { FileSystemWatcher, RelativePattern, WorkspaceFolder, workspace } from "vscode" 2 | import { registerDisposable, registerDisposables } from "./extension" 3 | import { PesterTestController } from "./pesterTestController" 4 | 5 | /** Registers Pester Test Controllers for each workspace folder in the workspace and monitors for changes */ 6 | export async function watchWorkspace() { 7 | // Create a test controller for each workspace folder 8 | workspace.onDidChangeWorkspaceFolders(async changedFolders => { 9 | const newTestControllers = changedFolders.added.map( 10 | folder => new PesterTestController(folder) 11 | ) 12 | registerDisposables(newTestControllers) 13 | newTestControllers.forEach(controller => controller.watch()) 14 | }) 15 | 16 | const watchers = new Set() 17 | 18 | // Register for current workspaces 19 | if (workspace.workspaceFolders !== undefined) { 20 | const newTestControllers = workspace.workspaceFolders.map( 21 | folder => new PesterTestController(folder) 22 | ) 23 | registerDisposables(newTestControllers) 24 | newTestControllers.forEach(controller => controller.watch()) 25 | } 26 | 27 | return watchers 28 | } 29 | 30 | /** 31 | * Starts up a filewatcher for each workspace and initialize a test controller for each workspace. 32 | * @param folder The workspace folder to watch 33 | * @param cb A callback to be called when a file change is detected 34 | */ 35 | export async function watchWorkspaceFolder(folder: WorkspaceFolder) { 36 | const testWatchers = new Map() 37 | 38 | for (const pattern of getPesterRelativePatterns(folder)) { 39 | // Register a filewatcher for each workspace's patterns 40 | const testWatcher = workspace.createFileSystemWatcher(pattern) 41 | registerDisposable(testWatcher) 42 | testWatchers.set(pattern, testWatcher) 43 | } 44 | return testWatchers 45 | } 46 | 47 | /** Returns a list of relative patterns based on user configuration for matching Pester files in the workspace */ 48 | export function getPesterRelativePatterns(workspaceFolder: WorkspaceFolder): RelativePattern[] { 49 | const pathsToWatch = workspace 50 | .getConfiguration('pester', workspaceFolder.uri) 51 | .get('testFilePath') 52 | 53 | if (!pathsToWatch) { 54 | throw new Error('No paths to watch found in user configuration') 55 | } 56 | 57 | return pathsToWatch.map(path => new RelativePattern(workspaceFolder, path)) 58 | } 59 | -------------------------------------------------------------------------------- /test/TestEnvironment.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | // A simple test environment that suppresses some first start warnings we don't care about. 3 | "folders": [ 4 | { 5 | "path": "../sample" 6 | } 7 | ], 8 | "settings": { 9 | "git.openRepositoryInParentFolders": "never", 10 | "extensions.ignoreRecommendations": true, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/extension.test.ts: -------------------------------------------------------------------------------- 1 | interface ShouldNotAppear { 2 | shouldNotAppear: string 3 | } 4 | 5 | describe('test', () => { 6 | it('test', () => { 7 | console.warn("SWCRC Setting", process.env.SWCRC) 8 | console.warn("TESTFLAG Setting", process.env.TESTFLAG) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2022", 5 | "lib": ["ES2022"], 6 | "outDir": "dist", 7 | "sourceMap": true, 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true 11 | }, 12 | "exclude": ["node_modules", ".vscode-test", "*.config.ts"] 13 | } 14 | --------------------------------------------------------------------------------