├── .github └── workflows │ └── build-and-test.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── __tests__ ├── SEARCH │ ├── sample1.cs │ └── sample2.cs ├── Utilities │ └── functional-tests │ │ ├── obj │ │ └── placeholder.txt │ │ └── vstest-functional-test.csproj ├── vstest.spec.ts └── vstest.tests.ts ├── action.yml ├── dist └── index.js ├── jest.config.js ├── jest.setup.js ├── jest.setup.template.json ├── package-lock.json ├── package.json ├── src ├── constants.ts ├── getArguments.ts ├── getTestAssemblies.ts ├── getVsTestPath.ts ├── index.ts ├── input-helper.ts ├── search.ts ├── upload-inputs.ts └── uploadArtifact.ts └── tsconfig.json /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: 'build-test' 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | branches: [ main ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | environment: Automation Test 21 | runs-on: windows-latest # The type of runner that the job will run on 22 | steps: 23 | 24 | - name: Checkout from PR branch 25 | uses: actions/checkout@v2 26 | 27 | - name: Installing node_modules 28 | run: npm install 29 | 30 | - name: Build GitHub Acton 31 | run: npm run build 32 | 33 | - name: Run Unit and Functional Tests 34 | run: npm run test 35 | 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Jest 35 | jest.setup.json 36 | 37 | # VS Code 38 | .vscode/ 39 | .vscode/launch.json 40 | .vscode/settings.json 41 | 42 | # Visual Studio 2015/2017 cache/options directory 43 | .vs/ 44 | # Uncomment if you have tasks that create the project's static files in wwwroot 45 | #wwwroot/ 46 | 47 | # Visual Studio 2017 auto generated files 48 | Generated\ Files/ 49 | 50 | # MSTest test Results 51 | [Tt]est[Rr]esult*/ 52 | [Bb]uild[Ll]og.* 53 | 54 | # NUnit 55 | *.VisualState.xml 56 | TestResult.xml 57 | nunit-*.xml 58 | 59 | # Build Results of an ATL Project 60 | [Dd]ebugPS/ 61 | [Rr]eleasePS/ 62 | dlldata.c 63 | 64 | # Benchmark Results 65 | BenchmarkDotNet.Artifacts/ 66 | 67 | # .NET Core 68 | project.lock.json 69 | project.fragment.lock.json 70 | artifacts/ 71 | 72 | # StyleCop 73 | StyleCopReport.xml 74 | 75 | # Files built by Visual Studio 76 | *_i.c 77 | *_p.c 78 | *_h.h 79 | *.ilk 80 | *.meta 81 | *.obj 82 | *.iobj 83 | *.pch 84 | *.pdb 85 | *.ipdb 86 | *.pgc 87 | *.pgd 88 | *.rsp 89 | *.sbr 90 | *.tlb 91 | *.tli 92 | *.tlh 93 | *.tmp 94 | *.tmp_proj 95 | *_wpftmp.csproj 96 | *.log 97 | *.vspscc 98 | *.vssscc 99 | .builds 100 | *.pidb 101 | *.svclog 102 | *.scc 103 | 104 | # Chutzpah Test files 105 | _Chutzpah* 106 | 107 | # Visual C++ cache files 108 | ipch/ 109 | *.aps 110 | *.ncb 111 | *.opendb 112 | *.opensdf 113 | *.sdf 114 | *.cachefile 115 | *.VC.db 116 | *.VC.VC.opendb 117 | 118 | # Visual Studio profiler 119 | *.psess 120 | *.vsp 121 | *.vspx 122 | *.sap 123 | 124 | # Visual Studio Trace Files 125 | *.e2e 126 | 127 | # TFS 2012 Local Workspace 128 | $tf/ 129 | 130 | # Guidance Automation Toolkit 131 | *.gpState 132 | 133 | # ReSharper is a .NET coding add-in 134 | _ReSharper*/ 135 | *.[Rr]e[Ss]harper 136 | *.DotSettings.user 137 | 138 | # TeamCity is a build add-in 139 | _TeamCity* 140 | 141 | # DotCover is a Code Coverage Tool 142 | *.dotCover 143 | 144 | # AxoCover is a Code Coverage Tool 145 | .axoCover/* 146 | !.axoCover/settings.json 147 | 148 | # Visual Studio code coverage results 149 | *.coverage 150 | *.coveragexml 151 | 152 | # NCrunch 153 | _NCrunch_* 154 | .*crunch*.local.xml 155 | nCrunchTemp_* 156 | 157 | # MightyMoose 158 | *.mm.* 159 | AutoTest.Net/ 160 | 161 | # Web workbench (sass) 162 | .sass-cache/ 163 | 164 | # Installshield output folder 165 | [Ee]xpress/ 166 | 167 | # DocProject is a documentation generator add-in 168 | DocProject/buildhelp/ 169 | DocProject/Help/*.HxT 170 | DocProject/Help/*.HxC 171 | DocProject/Help/*.hhc 172 | DocProject/Help/*.hhk 173 | DocProject/Help/*.hhp 174 | DocProject/Help/Html2 175 | DocProject/Help/html 176 | 177 | # Click-Once directory 178 | publish/ 179 | 180 | # Publish Web Output 181 | *.[Pp]ublish.xml 182 | *.azurePubxml 183 | # Note: Comment the next line if you want to checkin your web deploy settings, 184 | # but database connection strings (with potential passwords) will be unencrypted 185 | *.pubxml 186 | *.publishproj 187 | 188 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 189 | # checkin your Azure Web App publish settings, but sensitive information contained 190 | # in these scripts will be unencrypted 191 | PublishScripts/ 192 | 193 | # NuGet Packages 194 | *.nupkg 195 | # NuGet Symbol Packages 196 | *.snupkg 197 | # The packages folder can be ignored because of Package Restore 198 | **/[Pp]ackages/* 199 | # except build/, which is used as an MSBuild target. 200 | !**/[Pp]ackages/build/ 201 | # Uncomment if necessary however generally it will be regenerated when needed 202 | #!**/[Pp]ackages/repositories.config 203 | # NuGet v3's project.json files produces more ignorable files 204 | *.nuget.props 205 | *.nuget.targets 206 | 207 | # Microsoft Azure Build Output 208 | csx/ 209 | *.build.csdef 210 | 211 | # Microsoft Azure Emulator 212 | ecf/ 213 | rcf/ 214 | 215 | # Windows Store app package directories and files 216 | AppPackages/ 217 | BundleArtifacts/ 218 | Package.StoreAssociation.xml 219 | _pkginfo.txt 220 | *.appx 221 | *.appxbundle 222 | *.appxupload 223 | 224 | # Visual Studio cache files 225 | # files ending in .cache can be ignored 226 | *.[Cc]ache 227 | # but keep track of directories ending in .cache 228 | !?*.[Cc]ache/ 229 | 230 | # Others 231 | ClientBin/ 232 | ~$* 233 | *~ 234 | *.dbmdl 235 | *.dbproj.schemaview 236 | *.jfm 237 | *.pfx 238 | *.publishsettings 239 | orleans.codegen.cs 240 | 241 | # Including strong name files can present a security risk 242 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 243 | #*.snk 244 | 245 | # Since there are multiple workflows, uncomment next line to ignore bower_components 246 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 247 | #bower_components/ 248 | 249 | # RIA/Silverlight projects 250 | Generated_Code/ 251 | 252 | # Backup & report files from converting an old project file 253 | # to a newer Visual Studio version. Backup files are not needed, 254 | # because we have git ;-) 255 | _UpgradeReport_Files/ 256 | Backup*/ 257 | UpgradeLog*.XML 258 | UpgradeLog*.htm 259 | ServiceFabricBackup/ 260 | *.rptproj.bak 261 | 262 | # SQL Server files 263 | *.mdf 264 | *.ldf 265 | *.ndf 266 | 267 | # Business Intelligence projects 268 | *.rdl.data 269 | *.bim.layout 270 | *.bim_*.settings 271 | *.rptproj.rsuser 272 | *- [Bb]ackup.rdl 273 | *- [Bb]ackup ([0-9]).rdl 274 | *- [Bb]ackup ([0-9][0-9]).rdl 275 | 276 | # Microsoft Fakes 277 | FakesAssemblies/ 278 | 279 | # GhostDoc plugin setting file 280 | *.GhostDoc.xml 281 | 282 | # Node.js Tools for Visual Studio 283 | .ntvs_analysis.dat 284 | node_modules/ 285 | 286 | # Visual Studio 6 build log 287 | *.plg 288 | 289 | # Visual Studio 6 workspace options file 290 | *.opt 291 | 292 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 293 | *.vbw 294 | 295 | # Visual Studio LightSwitch build output 296 | **/*.HTMLClient/GeneratedArtifacts 297 | **/*.DesktopClient/GeneratedArtifacts 298 | **/*.DesktopClient/ModelManifest.xml 299 | **/*.Server/GeneratedArtifacts 300 | **/*.Server/ModelManifest.xml 301 | _Pvt_Extensions 302 | 303 | # Paket dependency manager 304 | .paket/paket.exe 305 | paket-files/ 306 | 307 | # FAKE - F# Make 308 | .fake/ 309 | 310 | # CodeRush personal settings 311 | .cr/personal 312 | 313 | # Python Tools for Visual Studio (PTVS) 314 | __pycache__/ 315 | *.pyc 316 | 317 | # Cake - Uncomment if you are using it 318 | # tools/** 319 | # !tools/packages.config 320 | 321 | # Tabs Studio 322 | *.tss 323 | 324 | # Telerik's JustMock configuration file 325 | *.jmconfig 326 | 327 | # BizTalk build output 328 | *.btp.cs 329 | *.btm.cs 330 | *.odx.cs 331 | *.xsd.cs 332 | 333 | # OpenCover UI analysis results 334 | OpenCover/ 335 | 336 | # Azure Stream Analytics local run output 337 | ASALocalRun/ 338 | 339 | # MSBuild Binary and Structured Log 340 | *.binlog 341 | 342 | # NVidia Nsight GPU debugger configuration file 343 | *.nvuser 344 | 345 | # MFractors (Xamarin productivity tool) working folder 346 | .mfractor/ 347 | 348 | # Local History for Visual Studio 349 | .localhistory/ 350 | 351 | # BeatPulse healthcheck temp database 352 | healthchecksdb 353 | 354 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 355 | MigrationBackup/ 356 | 357 | # Ionide (cross platform F# VS Code tools) working folder 358 | .ionide/ 359 | 360 | # GitHub Actions 361 | bin/ 362 | lib/ 363 | node_modules/ 364 | dist/.github/ 365 | TestResults/ 366 | obj/ 367 | coverage/ -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 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 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest: Current File", 11 | //"env": { "NODE_ENV": "test" }, 12 | "program": "${workspaceFolder}/node_modules/.bin/jest", 13 | "args": ["${fileBasenameNoExtension}", "--config", "jest.config.js"], 14 | "console": "integratedTerminal", 15 | "disableOptimisticBPs": true, 16 | "windows": { 17 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 18 | } 19 | }, 20 | { 21 | "name": "Jest", 22 | "type": "node", 23 | "request": "launch", 24 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 25 | "args": [ 26 | "-i" 27 | ], 28 | "internalConsoleOptions": "openOnSessionStart", 29 | "outFiles": [ 30 | "${workspaceRoot}/dist/**/*" 31 | ], 32 | "envFile": "${workspaceRoot}/.env" 33 | }, 34 | { 35 | "name": "ts-node", 36 | "type": "node", 37 | "request": "launch", 38 | "args": [ 39 | "${relativeFile}" 40 | ], 41 | "runtimeArgs": [ 42 | "-r", 43 | "ts-node/register" 44 | ], 45 | "cwd": "${workspaceRoot}", 46 | "protocol": "inspector", 47 | "internalConsoleOptions": "openOnSessionStart" 48 | } 49 | ] 50 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Filtercriteria", 4 | "vstest" 5 | ] 6 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VSTest GitHub Action 2 | 3 | This action was created to run tests using VSTest framework and to easily migrate a pipeline using [Azure DevOps VSTest task](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/test/vstest?view=azure-devops). Most of the commonly used properties in the [Azure DevOps VSTest task](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/test/vstest?view=azure-devops) map to properties of this GitHub action. Like the [Azure DevOps VSTest task](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/test/vstest?view=azure-devops), this action only supports `Windows` but NOT `Linux`. 4 | 5 | Due to the unavailability of a Test results UI, test results are displayed in the console logs of the action. 6 | 7 | ## Usage 8 | 9 | See [action.yml](action.yml) 10 | 11 | ## Example 12 | 13 | ```yaml 14 | jobs: 15 | my_action_job: 16 | runs-on: windows-latest 17 | name: A job to run VSTest 18 | steps: 19 | - name: Download tests binary zip 20 | run: powershell Invoke-WebRequest -Uri "https://localworker.blob.core.windows.net/win-x64/tests.zip" -OutFile "./tests.zip" 21 | - name: Unzip tests binary 22 | run: powershell Expand-Archive -Path tests.zip -DestinationPath ./ 23 | - name: Run tests 24 | uses: microsoft-approved-actions/vstest@master 25 | with: 26 | testAssembly: CloudTest.DefaultSamples*.dll 27 | searchFolder: ./tests/ 28 | runInParallel: true 29 | ``` 30 | 31 | ## Contributing 32 | 33 | This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit . 34 | 35 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. 36 | 37 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 38 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 39 | 40 | ## Trademarks 41 | 42 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow 43 | [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). 44 | 45 | Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. 46 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to file issues and get help 4 | 5 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 6 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 7 | feature request as a new Issue. 8 | 9 | For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE 10 | FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER 11 | CHANNEL. WHERE WILL YOU HELP PEOPLE?**. 12 | 13 | ## Microsoft Support Policy 14 | 15 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above. 16 | -------------------------------------------------------------------------------- /__tests__/SEARCH/sample1.cs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vstest-action/acb4bbc0c6d7f9fac1b1bf8b468934d9b5b584e6/__tests__/SEARCH/sample1.cs -------------------------------------------------------------------------------- /__tests__/SEARCH/sample2.cs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/vstest-action/acb4bbc0c6d7f9fac1b1bf8b468934d9b5b584e6/__tests__/SEARCH/sample2.cs -------------------------------------------------------------------------------- /__tests__/Utilities/functional-tests/obj/placeholder.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /__tests__/Utilities/functional-tests/vstest-functional-test.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /__tests__/vstest.spec.ts: -------------------------------------------------------------------------------- 1 | import {create, UploadOptions,UploadResponse} from '@actions/artifact' 2 | import * as glob from '@actions/glob' 3 | import * as core from '@actions/core' 4 | import * as exec from '@actions/exec' 5 | import * as path from 'path' 6 | import * as Search from '../src/search' 7 | import {getInputs} from '../src/input-helper' 8 | import {Inputs, NoFileOptions} from '../src/constants' 9 | import { run } from '../src/index' 10 | import {uploadArtifact} from '../src/uploadArtifact' 11 | import {getTestAssemblies} from '../src/getTestAssemblies' 12 | import {getArguments} from '../src/getArguments' 13 | import {getVsTestPath} from '../src/getVsTestPath' 14 | import {when} from 'jest-when' 15 | import mock from 'mock-fs' 16 | import * as fs from 'fs' 17 | import { stringify } from 'querystring' 18 | import { DirectoryItem } from 'mock-fs/lib/filesystem' 19 | 20 | describe('vstest Action Unit Tests', ()=>{ 21 | 22 | beforeEach(async() => { 23 | jest.mock('@actions/core'); 24 | jest.spyOn(core,'debug'); 25 | jest.spyOn(core, 'info'); 26 | jest.spyOn(core, 'getInput'); 27 | jest.spyOn(core, 'setFailed'); 28 | jest.spyOn(core, 'warning'); 29 | 30 | const globOptions : glob.GlobOptions = 31 | { 32 | followSymbolicLinks:false, 33 | implicitDescendants: true, 34 | omitBrokenSymbolicLinks: true 35 | } 36 | }) 37 | 38 | afterEach(async () => { 39 | jest.resetAllMocks() 40 | }) 41 | 42 | it('test filesToUpload with valid filenames', async () => { 43 | // Arrange 44 | const expectFiles:string[] = [ 45 | "C:\\Source\\Repos\\vstest-action\\tempFolder\\A.txt", 46 | "C:\\Source\\Repos\\vstest-action\\tempFolder\\Program.cs", 47 | "C:\\Source\\Repos\\vstest-action\\tempFolder\\vstest-functional-test.csproj" 48 | ] 49 | jest.mock('fs') 50 | mock({ 51 | // Recursively loads all node_modules 52 | 'node_modules': mock.load(path.resolve(__dirname, '../node_modules')), 53 | 'tempFolder': { 54 | 'A.txt': '# Hello world!', 55 | 'Program.cs': `using Microsoft.VisualStudio.TestTools.UnitTesting; 56 | namespace SimpleTestProject 57 | { 58 | [TestClass] 59 | public class UnitTest1 60 | { 61 | [TestMethod] 62 | public void TestMethod1() 63 | { 64 | } 65 | } 66 | }`, 67 | 'vstest-functional-test.csproj': ` 68 | 69 | 70 | net6.0 71 | enable 72 | 73 | false 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | ` 84 | } 85 | }) 86 | 87 | var searchFolder = "tempFolder" as string 88 | 89 | // Act 90 | let result = await Search.findFilesToUpload(searchFolder) 91 | 92 | // Assert 93 | expect(result.filesToUpload.length).toEqual(expectFiles.length) 94 | expect(result.filesToUpload[0].split("\\").slice(-1)).toEqual(expectFiles[0].split("\\").slice(-1)) 95 | mock.restore() 96 | }) 97 | 98 | it('test filesToUpload with filenames that conflict', async () => { 99 | // Arrange 100 | const expectFiles:string[] = [ 101 | "C:\\Source\\Repos\\vstest-action\\tempFolder\\A.txt", 102 | "C:\\Source\\Repos\\vstest-action\\tempFolder\\Program.cs", 103 | "C:\\Source\\Repos\\vstest-action\\tempFolder\\a.txt", 104 | "C:\\Source\\Repos\\vstest-action\\tempFolder\\vstest-functional-test.csproj" 105 | ] 106 | jest.mock('fs') 107 | mock({ 108 | // Recursively loads all node_modules 109 | 'node_modules': mock.load(path.resolve(__dirname, '../node_modules')), 110 | 'tempFolder': { 111 | 'A.txt': '# Hello world!', 112 | 'a.txt': '# hello world!', 113 | 'Program.cs': `using Microsoft.VisualStudio.TestTools.UnitTesting; 114 | namespace SimpleTestProject 115 | { 116 | [TestClass] 117 | public class UnitTest1 118 | { 119 | [TestMethod] 120 | public void TestMethod1() 121 | { 122 | } 123 | } 124 | }`, 125 | 'vstest-functional-test.csproj': ` 126 | 127 | 128 | net6.0 129 | enable 130 | 131 | false 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | ` 142 | } 143 | }) 144 | 145 | var searchFolder = "tempFolder" as string 146 | const globOptions : glob.GlobOptions = 147 | { 148 | followSymbolicLinks:false, 149 | implicitDescendants: false, 150 | omitBrokenSymbolicLinks: false 151 | } 152 | 153 | // Act 154 | let result = await Search.findFilesToUpload(searchFolder) 155 | 156 | // Assert 157 | expect(result.filesToUpload.length).toEqual(expectFiles.length) 158 | expect(result.filesToUpload[0].split("\\").slice(-1)).toEqual(expectFiles[0].split("\\").slice(-1)) 159 | mock.restore() 160 | }) 161 | 162 | it('test filesToUpload with complex search folder', async () => { 163 | // Arrange 164 | const expectFiles:string[] = [ 165 | "C:\\Source\\Repos\\vstest-action\\base1\\folder1\\A.txt", 166 | "C:\\Source\\Repos\\vstest-action\\base1\\folder1\\Program.cs", 167 | "C:\\Source\\Repos\\vstest-action\\base1\\folder1\\vstest-functional-test.csproj", 168 | "C:\\Source\\Repos\\vstest-action\\base2\\folder2\\A.txt", 169 | "C:\\Source\\Repos\\vstest-action\\base2\\folder2\\Program.cs", 170 | "C:\\Source\\Repos\\vstest-action\\base2\\folder2\\vstest-functional-test.csproj" 171 | ] 172 | jest.mock('fs') 173 | mock({ 174 | // Recursively loads all node_modules 175 | 'node_modules': mock.load(path.resolve(__dirname, '../node_modules')), 176 | 'base1/folder1/': { 177 | 'A.txt': '# Hello world!', 178 | 'Program.cs': `using Microsoft.VisualStudio.TestTools.UnitTesting; 179 | namespace SimpleTestProject 180 | { 181 | [TestClass] 182 | public class UnitTest1 183 | { 184 | [TestMethod] 185 | public void TestMethod1() 186 | { 187 | } 188 | } 189 | }`, 190 | 'vstest-functional-test.csproj': ` 191 | 192 | 193 | net6.0 194 | enable 195 | 196 | false 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | ` 207 | }, 208 | 'base2/folder2/': { 209 | 'A.txt': '# Hello world!', 210 | 'Program.cs': `using Microsoft.VisualStudio.TestTools.UnitTesting; 211 | namespace SimpleTestProject 212 | { 213 | [TestClass] 214 | public class UnitTest1 215 | { 216 | [TestMethod] 217 | public void TestMethod1() 218 | { 219 | } 220 | } 221 | }`, 222 | 'vstest-functional-test.csproj': ` 223 | 224 | 225 | net6.0 226 | enable 227 | 228 | false 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | ` 239 | } 240 | }) 241 | 242 | const searchPatterns: string[] = ['base1/folder1/*', 'base2/folder2/*'] 243 | var searchFolder: string = searchPatterns.join('\n') 244 | const globOptions : glob.GlobOptions = 245 | { 246 | followSymbolicLinks:false, 247 | implicitDescendants: true, 248 | omitBrokenSymbolicLinks: false 249 | } 250 | 251 | // Act 252 | let result = await Search.findFilesToUpload(searchFolder, globOptions) 253 | 254 | // Assert 255 | expect(result.filesToUpload.length).toEqual(expectFiles.length) 256 | expect(result.filesToUpload[0].split("\\").slice(-1)).toEqual(expectFiles[0].split("\\").slice(-1)) 257 | mock.restore() 258 | }) 259 | 260 | test.each([[NoFileOptions.warn, core.warning], [NoFileOptions.error, core.setFailed], [NoFileOptions.ignore, core.info]])('test uploadArtifact with ifNoFilesFound set to %s', async (a, expected) => { 261 | // Arrange 262 | jest.mock('fs') 263 | mock({ 264 | // Recursively loads all node_modules 265 | 'node_modules': mock.load(path.resolve(__dirname, '../node_modules')), 266 | 'tempFolder': { 267 | 'A.txt': '# Hello world!', 268 | 'Program.cs': `using Microsoft.VisualStudio.TestTools.UnitTesting; 269 | namespace SimpleTestProject 270 | { 271 | [TestClass] 272 | public class UnitTest1 273 | { 274 | [TestMethod] 275 | public void TestMethod1() 276 | { 277 | } 278 | } 279 | }`, 280 | 'vstest-functional-test.csproj': ` 281 | 282 | 283 | net6.0 284 | enable 285 | 286 | false 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | ` 297 | } 298 | }) 299 | 300 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 301 | when(coreGetInputMock) 302 | .calledWith(Inputs.Name).mockReturnValue('vstest-functional-test.csproj') 303 | .calledWith(Inputs.IfNoFilesFound).mockReturnValue('warn') 304 | .calledWith(Inputs.RetentionDays).mockReturnValue('30'); 305 | 306 | jest.spyOn(core, 'warning') 307 | jest.spyOn(core, 'info') 308 | jest.spyOn(core, 'setFailed') 309 | 310 | var filesToUploadValue: string[] = []; 311 | var rootDirectoryValue = __dirname; 312 | 313 | const searchResults = { 314 | filesToUpload: filesToUploadValue, 315 | rootDirectory: rootDirectoryValue 316 | }; 317 | 318 | jest.mock('../src/search'); 319 | const findFilesToUploadMock = jest.spyOn(Search, 'findFilesToUpload'); 320 | when(findFilesToUploadMock).mockResolvedValue(searchResults); 321 | 322 | // Act 323 | uploadArtifact(); 324 | 325 | // Assert 326 | expect(expected).toBeCalled 327 | }); 328 | 329 | }) 330 | -------------------------------------------------------------------------------- /__tests__/vstest.tests.ts: -------------------------------------------------------------------------------- 1 | import * as glob from '@actions/glob'; 2 | import * as core from '@actions/core'; 3 | import * as exec from '@actions/exec'; 4 | import * as path from 'path'; 5 | import * as Search from '../src/search'; 6 | import {getInputs} from '../src/input-helper'; 7 | import {Inputs, NoFileOptions} from '../src/constants' 8 | import { run } from '../src/index'; 9 | import {uploadArtifact} from '../src/uploadArtifact'; 10 | import {getTestAssemblies} from '../src/getTestAssemblies'; 11 | import {getArguments} from '../src/getArguments'; 12 | import {getVsTestPath} from '../src/getVsTestPath'; 13 | import {when} from 'jest-when'; 14 | import * as fs from 'fs' 15 | // const fs = require('fs') 16 | 17 | describe('vstest Action Unit Tests', ()=>{ 18 | 19 | beforeEach(async() => { 20 | let workerUri = "https://aka.ms/local-worker-win-x64"; 21 | // jest.resetModules(); 22 | // jest.resetAllMocks(); 23 | 24 | jest.mock('@actions/core'); 25 | jest.spyOn(core,'debug'); 26 | jest.spyOn(core, 'info'); 27 | jest.spyOn(core, 'getInput'); 28 | jest.spyOn(core, 'setFailed'); 29 | jest.spyOn(core, 'warning'); 30 | 31 | jest.mock('@actions/exec'); 32 | jest.spyOn(exec, 'exec'); 33 | 34 | jest.mock('path'); 35 | 36 | 37 | // jest.mock('../src/uploadArtifact'); 38 | 39 | }); 40 | 41 | afterEach(async () => { 42 | jest.resetAllMocks 43 | }) 44 | 45 | it("test getArguments with no inputs", async () => { 46 | 47 | jest.mock('@actions/core'); 48 | jest.spyOn(core,'debug'); 49 | jest.spyOn(core, 'info'); 50 | jest.spyOn(core, 'getInput'); 51 | 52 | // Arrange 53 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 54 | when(coreGetInputMock).calledWith('testFiltercriteria').mockReturnValue('') 55 | .calledWith('runSettingsFile').mockReturnValue('') 56 | .calledWith('pathToCustomTestAdapters').mockReturnValue('') 57 | .calledWith('runInParallel').mockReturnValue('false') 58 | .calledWith('runTestsInIsolation').mockReturnValue('false') 59 | .calledWith('codeCoverageEnabled').mockReturnValue('false') 60 | .calledWith('platform').mockReturnValue('') 61 | .calledWith('otherConsoleOptions').mockReturnValue(''); 62 | 63 | // Act 64 | var args = getArguments(); 65 | 66 | // Assert 67 | expect(args).not.toBeNull(); 68 | expect(args).toBe(''); 69 | 70 | }); 71 | 72 | it("test getArguments with all expected inputs", async () => { 73 | 74 | const expectedResult = '/TestCaseFilter:testFilterCriteria /Settings:runSettingsFile /TestAdapterPath:pathToCustomTestAdapters /Parallel /InIsolation /EnableCodeCoverage /Platform:x64 otherConsoleOptions ' 75 | jest.mock('@actions/core'); 76 | jest.spyOn(core,'debug'); 77 | jest.spyOn(core, 'info'); 78 | jest.spyOn(core, 'getInput'); 79 | 80 | // Arrange 81 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 82 | when(coreGetInputMock).calledWith('testFiltercriteria').mockReturnValue('testFilterCriteria') 83 | .calledWith('runSettingsFile').mockReturnValue('runSettingsFile') 84 | .calledWith('pathToCustomTestAdapters').mockReturnValue('pathToCustomTestAdapters') 85 | .calledWith('runInParallel').mockReturnValue('true') 86 | .calledWith('runTestsInIsolation').mockReturnValue('true') 87 | .calledWith('codeCoverageEnabled').mockReturnValue('true') 88 | .calledWith('platform').mockReturnValue('x64') 89 | .calledWith('otherConsoleOptions').mockReturnValue('otherConsoleOptions'); 90 | 91 | // Act 92 | var args = getArguments(); 93 | 94 | // Assert 95 | expect(args).not.toBeNull(); 96 | expect(args).toEqual(expectedResult); 97 | 98 | }); 99 | 100 | it("test getTestAssemblies with valid searchResults", async () => { 101 | 102 | // Arrange 103 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 104 | 105 | when(coreGetInputMock).calledWith('searchFolder').mockReturnValue('folderPath\\') 106 | .calledWith('testAssembly').mockReturnValue('testFile.sln'); 107 | 108 | const returnValue1 = core.getInput('searchFolder'); 109 | const returnValue2 = core.getInput('testAssembly'); 110 | 111 | var filesToUploadValue = ["testFile.zip"]; 112 | var rootDirectoryValue = "C:\\Users\\Public\\"; 113 | 114 | const searchResults = { 115 | filesToUpload: filesToUploadValue, 116 | rootDirectory: rootDirectoryValue 117 | }; 118 | 119 | jest.mock('../src/search'); 120 | const findFilesToUploadMock = jest.spyOn(Search, 'findFilesToUpload'); 121 | when(findFilesToUploadMock).mockResolvedValue(searchResults); 122 | 123 | // Act 124 | var testAssembly = await getTestAssemblies(); 125 | 126 | // Assert 127 | expect(testAssembly).not.toBeNull() 128 | findFilesToUploadMock.mockReset() 129 | }); 130 | 131 | it("test getTestAssemblies with empty searchResults", async () => { 132 | 133 | // Arrange 134 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 135 | 136 | when(coreGetInputMock).calledWith('searchFolder').mockReturnValue('folderPath\\') 137 | .calledWith('testAssembly').mockReturnValue('testFile.sln'); 138 | 139 | const returnValue1 = core.getInput('searchFolder'); 140 | const returnValue2 = core.getInput('testAssembly'); 141 | 142 | var filesToUploadValue = ['']; 143 | var rootDirectoryValue = "C:\\Users\\Public\\"; 144 | 145 | const searchResults = { 146 | filesToUpload: filesToUploadValue, 147 | rootDirectory: rootDirectoryValue 148 | }; 149 | 150 | jest.mock('../src/search'); 151 | const findFilesToUploadMock = jest.spyOn(Search, 'findFilesToUpload'); 152 | when(findFilesToUploadMock).mockResolvedValue(searchResults); 153 | const expectedResult : string[] = new Array(''); 154 | 155 | // Act 156 | var testAssembly = await getTestAssemblies(); 157 | 158 | // Assert 159 | expect(testAssembly).toEqual(expectedResult) 160 | findFilesToUploadMock.mockReset() 161 | }); 162 | 163 | it('getTestAssemblies throws exception', async () => { 164 | 165 | // Arrange 166 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 167 | 168 | when(coreGetInputMock).calledWith('searchFolder').mockReturnValue('folderPath\\') 169 | .calledWith('testAssembly').mockReturnValue('testFile.sln'); 170 | 171 | const returnValue1 = core.getInput('searchFolder'); 172 | const returnValue2 = core.getInput('testAssembly'); 173 | 174 | var filesToUploadValue = ['']; 175 | var rootDirectoryValue = "C:\\Users\\Public\\"; 176 | 177 | const searchResults = { 178 | filesToUpload: filesToUploadValue, 179 | rootDirectory: rootDirectoryValue 180 | }; 181 | 182 | jest.mock('../src/search'); 183 | const findFilesToUploadMock = jest.spyOn(Search, 'findFilesToUpload'); 184 | when(findFilesToUploadMock).mockImplementation(() => { throw new Error('Sample Error') }); 185 | 186 | const coresStFailedSpyOn = jest.spyOn(core, 'setFailed'); 187 | 188 | // Act 189 | var testAssembly = await getTestAssemblies(); 190 | findFilesToUploadMock.mockRestore(); 191 | 192 | // Assert 193 | expect(testAssembly.length).toEqual(0) 194 | findFilesToUploadMock.mockReset() 195 | }) 196 | 197 | it('test getInputs with valid values', async () => { 198 | // Arrange 199 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 200 | const coreSetFailedMock = jest.spyOn(core, 'setFailed'); 201 | when(coreGetInputMock) 202 | .calledWith(Inputs.Name).mockReturnValue('testFile.sln') 203 | .calledWith(Inputs.IfNoFilesFound).mockReturnValue('warn') 204 | .calledWith(Inputs.RetentionDays).mockReturnValue('30'); 205 | 206 | // Act 207 | var results = getInputs(); 208 | 209 | // Assert 210 | expect(results).not.toBeNull(); 211 | 212 | }); 213 | 214 | it('test getInputs with invalid RetentionDays', async () => { 215 | // Arrange 216 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 217 | const coreSetFailedMock = jest.spyOn(core, 'setFailed'); 218 | when(coreGetInputMock) 219 | .calledWith(Inputs.Name).mockReturnValue('testFile.sln') 220 | .calledWith(Inputs.IfNoFilesFound).mockReturnValue('warn') 221 | .calledWith(Inputs.RetentionDays).mockReturnValue('xx'); 222 | 223 | // Act 224 | var results = getInputs(); 225 | 226 | // Assert 227 | expect(results).not.toBeNull(); 228 | 229 | }); 230 | 231 | it('test getInputs with ifNoFilesFound values', async () => { 232 | // Arrange 233 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 234 | const coreSetFailedMock = jest.spyOn(core, 'setFailed'); 235 | when(coreGetInputMock) 236 | .calledWith(Inputs.Name).mockReturnValue('testFile.sln') 237 | .calledWith(Inputs.IfNoFilesFound).mockReturnValue('ifNoFilesFound') 238 | .calledWith(Inputs.RetentionDays).mockReturnValue('30'); 239 | 240 | // Act 241 | var results = getInputs(); 242 | 243 | // Assert 244 | expect(results).not.toBeNull(); 245 | 246 | }); 247 | 248 | it('test findFilesToUpload with valid values', async () => { 249 | // Arrange 250 | jest.mock('@actions/glob') 251 | const coreGetInputMock = jest.spyOn(core, 'getInput') 252 | const globCreateMock = jest.spyOn(glob, 'create') 253 | const fs = require('fs') 254 | const mocked = fs as jest.Mocked 255 | 256 | jest.spyOn(fs, 'existsSync') 257 | fs.existsSync.mockReturnValue(false) 258 | const fsStatSyncMock = jest.spyOn(fs, 'statSync') 259 | when(fsStatSyncMock).calledWith().mockReturnValue(true) 260 | 261 | when(coreGetInputMock) 262 | .calledWith(Inputs.Name).mockReturnValue('testFile.sln') 263 | .calledWith(Inputs.IfNoFilesFound).mockReturnValue('warn') 264 | .calledWith(Inputs.RetentionDays).mockReturnValue('30') 265 | 266 | var searchFolder = "C:\\Temp\\" as string; 267 | 268 | var rawSearchResults = ["C:\\Temp\\Folder1","C:\\Temp\\Folder2","C:\\Temp\\Folder3"] 269 | 270 | const globOptions : glob.GlobOptions = 271 | { 272 | followSymbolicLinks:false, 273 | implicitDescendants: false, 274 | omitBrokenSymbolicLinks: false 275 | } 276 | 277 | var globCreationResultMock = when(globCreateMock).calledWith(searchFolder,globOptions).mockReturnThis 278 | // var y = when(globCreationResultMock).calledWith().mockReturnValue(rawSearchResults) 279 | 280 | // Act 281 | var results = await Search.findFilesToUpload(searchFolder, globOptions) 282 | 283 | // Assert 284 | expect(results).not.toBeNull() 285 | 286 | }); 287 | 288 | it('test findFilesToUpload with temp folder', async () => { 289 | // Arrange 290 | var searchFolder = "C:\\Temp\\branch1\\folder1\\vstest-functional-test.csproj C:\\Temp\\branch2\\folder2\\vstest-functional-test.csproj" as string 291 | const globOptions : glob.GlobOptions = 292 | { 293 | followSymbolicLinks:false, 294 | implicitDescendants: false, 295 | omitBrokenSymbolicLinks: false 296 | } 297 | 298 | // Act 299 | let result = await Search.findFilesToUpload(searchFolder) 300 | 301 | // Assert 302 | expect(result.filesToUpload).toBeNull 303 | }) 304 | 305 | it('test findFilesToUpload with non-existent folder', async () => { 306 | // Arrange 307 | var searchFolder = "" as string 308 | 309 | // Act 310 | let result = await Search.findFilesToUpload(searchFolder) 311 | 312 | // Assert 313 | expect(result.filesToUpload).toBeNull 314 | }) 315 | 316 | 317 | it('test findFilesToUpload with temp subfolder', async () => { 318 | // Arrange 319 | var searchFolder = "C:\\Temp\\*\\*" as string 320 | const globOptions : glob.GlobOptions = 321 | { 322 | followSymbolicLinks:false, 323 | implicitDescendants: false, 324 | omitBrokenSymbolicLinks: false 325 | } 326 | 327 | // Act 328 | let result = await Search.findFilesToUpload(searchFolder, globOptions) 329 | 330 | // Assert 331 | expect(result.filesToUpload).toBeNull 332 | }) 333 | 334 | it('test findFilesToUpload with temp folder without glob options', async () => { 335 | // Arrange 336 | var searchFolder = "C:\\Temp\\folder1" as string 337 | const globOptions : glob.GlobOptions = 338 | { 339 | followSymbolicLinks:false, 340 | implicitDescendants: false, 341 | omitBrokenSymbolicLinks: false 342 | } 343 | 344 | // Act 345 | let result = await Search.findFilesToUpload(searchFolder) 346 | 347 | // Assert 348 | expect(result.filesToUpload).toBeNull 349 | }) 350 | 351 | it('test findFilesToUpload with zip file', async () => { 352 | // Arrange 353 | var searchFolder = "C:\\Temp\\testCase.zip" as string 354 | const globOptions : glob.GlobOptions = 355 | { 356 | followSymbolicLinks:false, 357 | implicitDescendants: false, 358 | omitBrokenSymbolicLinks: false 359 | } 360 | 361 | // Act 362 | let result = await Search.findFilesToUpload(searchFolder, globOptions) 363 | 364 | // Assert 365 | expect(result.filesToUpload).toBeNull 366 | }) 367 | 368 | it('test uploadArtifact', async () => { 369 | jest.mock('@actions/core'); 370 | jest.spyOn(core,'debug'); 371 | jest.spyOn(core, 'info'); 372 | jest.spyOn(core, 'getInput'); 373 | 374 | // Arrange 375 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 376 | when(coreGetInputMock).calledWith('testFiltercriteria').mockReturnValue('') 377 | .calledWith('runSettingsFile').mockReturnValue('') 378 | .calledWith('pathToCustomTestAdapters').mockReturnValue('') 379 | .calledWith('runInParallel').mockReturnValue('false') 380 | .calledWith('runTestsInIsolation').mockReturnValue('false') 381 | .calledWith('codeCoverageEnabled').mockReturnValue('false') 382 | .calledWith('platform').mockReturnValue('') 383 | .calledWith('otherConsoleOptions').mockReturnValue(''); 384 | 385 | // Act 386 | var args = uploadArtifact(); 387 | 388 | // Assert 389 | expect(args).not.toBeNull(); 390 | 391 | }); 392 | 393 | it("test uploadArtifact with valid searchResults", async () => { 394 | 395 | // Arrange 396 | const coreGetInputMock = jest.spyOn(core, 'getInput'); 397 | 398 | when(coreGetInputMock).calledWith('searchFolder').mockReturnValue('folderPath\\') 399 | .calledWith('testAssembly').mockReturnValue('testFile.sln'); 400 | 401 | const returnValue1 = core.getInput('searchFolder'); 402 | const returnValue2 = core.getInput('testAssembly'); 403 | 404 | var filesToUploadValue = ["testFile.zip"]; 405 | var rootDirectoryValue = "C:\\Users\\Public\\"; 406 | 407 | const searchResults = { 408 | filesToUpload: filesToUploadValue, 409 | rootDirectory: rootDirectoryValue 410 | }; 411 | 412 | jest.mock('../src/search'); 413 | const findFilesToUploadMock = jest.spyOn(Search, 'findFilesToUpload'); 414 | when(findFilesToUploadMock).mockResolvedValue(searchResults); 415 | 416 | // Act 417 | var testAssembly = await uploadArtifact(); 418 | 419 | // Assert 420 | expect(testAssembly).not.toBeNull() 421 | findFilesToUploadMock.mockReset() 422 | }); 423 | 424 | 425 | // test('test findFilesToUpload with empty searchFolder', async () => { 426 | // var searchFolder = "" as string 427 | 428 | // let result = await Search.findFilesToUpload(searchFolder) 429 | // expect(result.filesToUpload).toBeNull 430 | // }) 431 | 432 | // test('test findFilesToUpload', async () => { 433 | // var searchFolder = process.env['RUNNER_SEARCH'] as string 434 | 435 | // const expectedResult : string[] = new Array(''); 436 | 437 | // // jest.mock('fs'); 438 | // // jest.spyOn(stat.prototype, 'stats'); 439 | 440 | // let result = await Search.findFilesToUpload(searchFolder) 441 | // expect(result.filesToUpload).toEqual(expectedResult); 442 | // }) 443 | 444 | // test('vstest', async () => { 445 | // await run() 446 | // }) 447 | }); 448 | 449 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "vstest-action" 2 | description: "Run VSTest and upload result logs" 3 | inputs: 4 | testAssembly: 5 | description: "Run tests from the specified files" 6 | required: true 7 | default: '**\\*test*.dll\n!**\\*TestAdapter.dll\n!**\\obj\\**' 8 | 9 | searchFolder: 10 | description: "Folder to search for the test assemblies" 11 | required: true 12 | 13 | testFiltercriteria: 14 | description: "Additional criteria to filter tests from Test assemblies" 15 | required: false 16 | 17 | vstestLocationMethod: 18 | description: 'Specify which test platform should be used. Valid values are: `version` and `location`)' 19 | required: false 20 | 21 | vsTestVersion: 22 | description: "The version of Visual Studio test to use. If latest is specified it chooses Visual Studio 2017 or Visual Studio 2015 depending on what is installed. Visual Studio 2013 is not supported. Valid values are: `latest`, `14.0`, `15.0`, and `16.0`" 23 | required: false 24 | 25 | vstestLocation: 26 | description: "Specify the path to VSTest" 27 | required: false 28 | 29 | runSettingsFile: 30 | description: "Path to runsettings or testsettings file to use with the tests" 31 | required: false 32 | 33 | pathToCustomTestAdapters: 34 | description: "Directory path to custom test adapters. Adapters residing in the same folder as the test assemblies are automatically discovered" 35 | required: false 36 | 37 | runInParallel: 38 | description: "If set, tests will run in parallel leveraging available cores of the machine. This will override the MaxCpuCount if specified in your runsettings file. Valid values are: `true` and `false`" 39 | required: false 40 | 41 | runTestsInIsolation: 42 | description: "Runs the tests in an isolated process. This makes vstest.console.exe process less likely to be stopped on an error in the tests, but tests might run slower. Valid values are: `true` and `false`" 43 | required: false 44 | 45 | codeCoverageEnabled: 46 | description: "Collect code coverage information from the test run" 47 | required: false 48 | 49 | otherConsoleOptions: 50 | description: "Other console options that can be passed to vstest.console.exe" 51 | required: false 52 | 53 | platform: 54 | description: "Build platform against which the tests should be reported. Valid values are: `x86`, `x64`, and `ARM`" 55 | required: false 56 | 57 | resultLogsArtifactName: 58 | description: "Test result logs artifact name" 59 | required: true 60 | default: "vs-test-result-logs" 61 | 62 | ifNoFilesFound: 63 | description: > 64 | The desired behavior if no files are found using the provided path. 65 | Available Options: 66 | warn: Output a warning but do not fail the action 67 | error: Fail the action with an error message 68 | ignore: Do not output any warnings or errors, the action does not fail 69 | default: "warn" 70 | 71 | retentionDays: 72 | description: > 73 | Duration after which artifact will expire in days. 0 means using default retention. 74 | Minimum 1 day. 75 | Maximum 90 days unless changed from the repository settings page. 76 | 77 | runs: 78 | using: "node12" 79 | main: "dist/index.js" 80 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | "transform": { 5 | ".(ts|tsx)": "/node_modules/ts-jest/preprocessor.js" 6 | }, 7 | "testRegex": "(/__tests__/.*|\\.(tests|spec))\\.(ts|tsx|js)$", 8 | testEnvironment: 'node', 9 | testRunner: 'jest-circus/runner', 10 | transform: { 11 | '^.+\\.ts$': 'ts-jest' 12 | }, 13 | verbose: true, 14 | collectCoverage: true, 15 | coverageReporters: ["json", "html"], 16 | setupFilesAfterEnv: ['./jest.setup.js'] 17 | } -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const io = require('@actions/io') 4 | const data = require('./jest.setup.template.json') 5 | 6 | jest.setTimeout(60000) // in milliseconds 7 | 8 | // Set temp and tool directories before importing (used to set global state) 9 | const cachePath = path.join(__dirname, '__tests__', 'CACHE') 10 | const tempPath = path.join(__dirname, '__tests__', 'TEMP') 11 | const searchPath = path.join(__dirname,'__tests__', 'SEARCH' ) 12 | 13 | // Define all the environment variables 14 | process.env['RUNNER_SEARCH'] = searchPath 15 | process.env['RUNNER_TEMP'] = tempPath 16 | process.env['RUNNER_TOOL_CACHE'] = cachePath 17 | 18 | // Set up all the user defined variables and inputs from jest.setup.json 19 | setUserVars() 20 | 21 | function setUserVars() { 22 | data.envVars.forEach(function(envVar) { 23 | process.env[envVar.name] = envVar.value 24 | }); 25 | data.inputs.forEach(function(input) { 26 | setVar(input.name, input.value) 27 | }); 28 | } 29 | 30 | if (!fs.existsSync(tempPath)) { 31 | io.mkdirP(tempPath) 32 | } 33 | 34 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 35 | function setVar(name, value) { 36 | process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] = value 37 | } -------------------------------------------------------------------------------- /jest.setup.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "envVars": [ 3 | { "name": "RUNNER_WORKSPACE", "value": "__WORKSPACE__" }, 4 | { "name": "GITHUB_REPOSITORY", "value": "__REPO__" } 5 | ], 6 | "inputs": [ 7 | { "name": "testAssembly", "value": "__TESTASSEMBLY__" }, 8 | { "name": "searchFolder", "value": "__SEARCHFOLDER__" } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "SET NODE_OPTIONS=--openssl-legacy-provider && ncc build src\\index.ts", 4 | "test": "jest vstest.tests.ts --coverage", 5 | "functional-test": "jest vstest.spec.ts" 6 | }, 7 | "dependencies": { 8 | "@actions/artifact": "^0.5.2", 9 | "@actions/core": "^1.2.6", 10 | "@actions/exec": "^1.1.0", 11 | "@actions/github": "^1.1.0", 12 | "@actions/glob": "^0.1.0", 13 | "@actions/http-client": "^1.0.8", 14 | "@actions/io": "^1.0.2", 15 | "@types/jest-when": "^3.5.0", 16 | "@types/mock-fs": "^4.13.1", 17 | "fast-xml-parser": "^3.15.1", 18 | "node": "^17.7.2", 19 | "semver": "^6.3.0", 20 | "xmlbuilder": "^13.0.2" 21 | }, 22 | "devDependencies": { 23 | "@babel/cli": "^7.17.6", 24 | "@babel/core": "^7.17.8", 25 | "@babel/preset-env": "^7.16.11", 26 | "@types/jest": "^27.5.0", 27 | "@types/node": "^12.12.62", 28 | "@types/semver": "^6.2.2", 29 | "@zeit/ncc": "^0.21.1", 30 | "jest": "^27.5.1", 31 | "jest-mock-extended": "^2.0.5", 32 | "jest-when": "^3.5.1", 33 | "mock-fs": "^5.1.2", 34 | "ts-jest": "^27.1.3", 35 | "typescript": "^3.9.10", 36 | "wget-improved": "^1.3.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export enum Inputs { 2 | Name = 'resultLogsArtifactName', 3 | IfNoFilesFound = 'ifNoFilesFound', 4 | RetentionDays = 'retentionDays' 5 | } 6 | 7 | export enum NoFileOptions { 8 | /** 9 | * Default. Output a warning but do not fail the action 10 | */ 11 | warn = 'warn', 12 | 13 | /** 14 | * Fail the action with an error message 15 | */ 16 | error = 'error', 17 | 18 | /** 19 | * Do not output any warnings or errors, the action does not fail 20 | */ 21 | ignore = 'ignore' 22 | } -------------------------------------------------------------------------------- /src/getArguments.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as exec from '@actions/exec'; 3 | import * as path from 'path'; 4 | import {create, UploadOptions} from '@actions/artifact'; 5 | import {findFilesToUpload} from './search'; 6 | import {getInputs} from './input-helper'; 7 | import {NoFileOptions} from './constants'; 8 | 9 | export function getArguments(): string { 10 | let args = '' 11 | let testFiltercriteria = core.getInput('testFiltercriteria') 12 | if(testFiltercriteria) { 13 | args += `/TestCaseFilter:${testFiltercriteria} ` 14 | } 15 | 16 | let runSettingsFile = core.getInput('runSettingsFile') 17 | if(runSettingsFile) { 18 | args += `/Settings:${runSettingsFile} ` 19 | } 20 | 21 | let pathToCustomTestAdapters = core.getInput('pathToCustomTestAdapters') 22 | if(pathToCustomTestAdapters) { 23 | args += `/TestAdapterPath:${pathToCustomTestAdapters} ` 24 | } 25 | 26 | let runInParallel = core.getInput('runInParallel') 27 | if(runInParallel && runInParallel.toUpperCase() === "TRUE") { 28 | args += `/Parallel ` 29 | } 30 | 31 | let runTestsInIsolation = core.getInput('runTestsInIsolation') 32 | if(runTestsInIsolation && runTestsInIsolation.toUpperCase() === "TRUE") { 33 | args += `/InIsolation ` 34 | } 35 | 36 | let codeCoverageEnabled = core.getInput('codeCoverageEnabled') 37 | if(codeCoverageEnabled && codeCoverageEnabled.toUpperCase() === "TRUE") { 38 | args += `/EnableCodeCoverage ` 39 | } 40 | 41 | let platform = core.getInput('platform') 42 | if(platform && (platform === "x86" || platform === "x64" || platform === "ARM")) { 43 | args += `/Platform:${platform} ` 44 | } 45 | 46 | let otherConsoleOptions = core.getInput('otherConsoleOptions') 47 | if(otherConsoleOptions) { 48 | args += `${otherConsoleOptions} ` 49 | } 50 | 51 | return args 52 | } 53 | 54 | -------------------------------------------------------------------------------- /src/getTestAssemblies.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import {findFilesToUpload} from './search'; 3 | 4 | export async function getTestAssemblies(): Promise { 5 | try { 6 | let searchFolder = core.getInput('searchFolder') 7 | let testAssembly = core.getInput('testAssembly') 8 | 9 | core.debug(`Pattern to search test assemblies: ${searchFolder + testAssembly}`) 10 | const searchResult = await findFilesToUpload(searchFolder + testAssembly) 11 | 12 | return searchResult.filesToUpload 13 | } catch (err) { 14 | core.setFailed(err.message) 15 | } 16 | return [] 17 | } 18 | -------------------------------------------------------------------------------- /src/getVsTestPath.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as exec from '@actions/exec'; 3 | import * as path from 'path'; 4 | import {create, UploadOptions} from '@actions/artifact'; 5 | import {findFilesToUpload} from './search'; 6 | import {getInputs} from './input-helper'; 7 | import {NoFileOptions} from './constants'; 8 | 9 | export function getVsTestPath(): string { 10 | let vstestLocationMethod = core.getInput('vstestLocationMethod') 11 | if(vstestLocationMethod && vstestLocationMethod.toUpperCase() === "LOCATION") { 12 | return core.getInput('vstestLocation') 13 | } 14 | 15 | let vsTestVersion = core.getInput('vsTestVersion') 16 | if(vsTestVersion && vsTestVersion === "14.0") { 17 | return path.join(__dirname, 'win-x64/VsTest/v140/vstest.console.exe') 18 | } 19 | 20 | if(vsTestVersion && vsTestVersion === "15.0") { 21 | return path.join(__dirname, 'win-x64/VsTest/v150/Common7/IDE/Extensions/TestPlatform/vstest.console.exe') 22 | } 23 | 24 | return path.join(__dirname, 'win-x64/VsTest/v160/Common7/IDE/Extensions/TestPlatform/vstest.console.exe') 25 | } 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as exec from '@actions/exec'; 3 | import * as path from 'path'; 4 | import {uploadArtifact} from './uploadArtifact' 5 | import {getTestAssemblies} from './getTestAssemblies' 6 | import {getArguments} from './getArguments' 7 | import {getVsTestPath} from './getVsTestPath' 8 | 9 | export async function run() { 10 | try { 11 | let testFiles = await getTestAssemblies(); 12 | if(testFiles.length == 0) { 13 | throw new Error('No matched test files!') 14 | } 15 | 16 | core.debug(`Matched test files are:`) 17 | testFiles.forEach(function (file) { 18 | core.debug(`${file}`) 19 | }); 20 | 21 | core.info(`Downloading test tools...`); 22 | let workerZipPath = path.join(__dirname, 'win-x64.zip') 23 | await exec.exec(`powershell Invoke-WebRequest -Uri "https://aka.ms/local-worker-win-x64" -OutFile ${workerZipPath}`); 24 | 25 | core.info(`Unzipping test tools...`); 26 | core.debug(`workerZipPath is ${workerZipPath}`); 27 | await exec.exec(`powershell Expand-Archive -Path ${workerZipPath} -DestinationPath ${__dirname}`); 28 | 29 | let vsTestPath = getVsTestPath(); 30 | core.debug(`VsTestPath: ${vsTestPath}`); 31 | 32 | let args = getArguments(); 33 | core.debug(`Arguments: ${args}`); 34 | 35 | core.info(`Running tests...`); 36 | await exec.exec(`${vsTestPath} ${testFiles.join(' ')} ${args} /Logger:TRX`); 37 | } catch (err) { 38 | core.setFailed(err.message) 39 | } 40 | 41 | // Always attempt to upload test result artifact 42 | try { 43 | await uploadArtifact(); 44 | } catch (err) { 45 | core.setFailed(err.message) 46 | } 47 | } 48 | 49 | run() -------------------------------------------------------------------------------- /src/input-helper.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {Inputs, NoFileOptions} from './constants' 3 | import {UploadInputs} from './upload-inputs' 4 | 5 | /** 6 | * Helper to get all the inputs for the action 7 | */ 8 | export function getInputs(): UploadInputs { 9 | const name = core.getInput(Inputs.Name) 10 | const path = 'TestResults' 11 | 12 | const ifNoFilesFound: string = core.getInput(Inputs.IfNoFilesFound) 13 | const noFileBehavior: NoFileOptions = NoFileOptions[ifNoFilesFound as keyof typeof NoFileOptions] 14 | 15 | if (!noFileBehavior) { 16 | core.setFailed( 17 | `Unrecognized ${ 18 | Inputs.IfNoFilesFound 19 | } input. Provided: ${ifNoFilesFound}. Available options: ${Object.keys( 20 | NoFileOptions 21 | )}` 22 | ) 23 | } 24 | 25 | const inputs = { 26 | artifactName: name, 27 | searchPath: path, 28 | ifNoFilesFound: noFileBehavior 29 | } as UploadInputs 30 | 31 | const retentionDaysStr = core.getInput(Inputs.RetentionDays) 32 | if (retentionDaysStr) { 33 | inputs.retentionDays = parseInt(retentionDaysStr) 34 | if (isNaN(inputs.retentionDays)) { 35 | core.setFailed('Invalid retention-days') 36 | } 37 | } 38 | 39 | return inputs 40 | } -------------------------------------------------------------------------------- /src/search.ts: -------------------------------------------------------------------------------- 1 | import * as glob from '@actions/glob' 2 | import * as path from 'path' 3 | import {debug, info} from '@actions/core' 4 | import {stat} from 'fs' 5 | import {dirname} from 'path' 6 | import {promisify} from 'util' 7 | const stats = promisify(stat) 8 | 9 | export interface SearchResult { 10 | filesToUpload: string[] 11 | rootDirectory: string 12 | } 13 | 14 | function getDefaultGlobOptions(): glob.GlobOptions { 15 | return { 16 | followSymbolicLinks: true, 17 | implicitDescendants: true, 18 | omitBrokenSymbolicLinks: true 19 | } 20 | } 21 | 22 | /** 23 | * If multiple paths are specific, the least common ancestor (LCA) of the search paths is used as 24 | * the delimiter to control the directory structure for the artifact. This function returns the LCA 25 | * when given an array of search paths 26 | * 27 | * Example 1: The patterns `/foo/` and `/bar/` returns `/` 28 | * 29 | * Example 2: The patterns `~/foo/bar/*` and `~/foo/voo/two/*` and `~/foo/mo/` returns `~/foo` 30 | */ 31 | function getMultiPathLCA(searchPaths: string[]): string { 32 | // if (searchPaths.length < 2) { 33 | // throw new Error('At least two search paths must be provided') 34 | // } 35 | 36 | const commonPaths = new Array() 37 | const splitPaths = new Array() 38 | let smallestPathLength = Number.MAX_SAFE_INTEGER 39 | 40 | // split each of the search paths using the platform specific separator 41 | for (const searchPath of searchPaths) { 42 | debug(`Using search path ${searchPath}`) 43 | 44 | const splitSearchPath = path.normalize(searchPath).split(path.sep) 45 | 46 | // keep track of the smallest path length so that we don't accidentally later go out of bounds 47 | smallestPathLength = Math.min(smallestPathLength, splitSearchPath.length) 48 | splitPaths.push(splitSearchPath) 49 | } 50 | 51 | // on Unix-like file systems, the file separator exists at the beginning of the file path, make sure to preserve it 52 | if (searchPaths[0].startsWith(path.sep)) { 53 | commonPaths.push(path.sep) 54 | } 55 | 56 | let splitIndex = 0 57 | // function to check if the paths are the same at a specific index 58 | function isPathTheSame(): boolean { 59 | const compare = splitPaths[0][splitIndex] 60 | for (let i = 1; i < splitPaths.length; i++) { 61 | if (compare !== splitPaths[i][splitIndex]) { 62 | // a non-common index has been reached 63 | return false 64 | } 65 | } 66 | return true 67 | } 68 | 69 | // loop over all the search paths until there is a non-common ancestor or we go out of bounds 70 | while (splitIndex < smallestPathLength) { 71 | if (!isPathTheSame()) { 72 | break 73 | } 74 | // if all are the same, add to the end result & increment the index 75 | commonPaths.push(splitPaths[0][splitIndex]) 76 | splitIndex++ 77 | } 78 | return path.join(...commonPaths) 79 | } 80 | 81 | export async function findFilesToUpload( searchPath: string, globOptions?: glob.GlobOptions): Promise 82 | { 83 | const searchResults: string[] = [] 84 | const globCreationResult = await glob.create(searchPath,globOptions || getDefaultGlobOptions()) 85 | const rawSearchResults: string[] = await globCreationResult.glob() 86 | 87 | /* 88 | Files are saved with case insensitivity. Uploading both a.txt and A.txt will files to be overwritten 89 | Detect any files that could be overwritten for user awareness 90 | */ 91 | const set = new Set() 92 | 93 | /* 94 | Directories will be rejected if attempted to be uploaded. This includes just empty 95 | directories so filter any directories out from the raw search results 96 | */ 97 | for (const searchResult of rawSearchResults) { 98 | const fileStats = await stats(searchResult) 99 | // isDirectory() returns false for symlinks if using fs.lstat(), make sure to use fs.stat() instead 100 | if (!fileStats.isDirectory()) { 101 | debug(`File:${searchResult} was found using the provided searchPath`) 102 | searchResults.push(searchResult) 103 | 104 | // detect any files that would be overwritten because of case insensitivity 105 | if (set.has(searchResult.toLowerCase())) { 106 | info( 107 | `Uploads are case insensitive: ${searchResult} was detected that it will be overwritten by another file with the same path` 108 | ) 109 | } else { 110 | set.add(searchResult.toLowerCase()) 111 | } 112 | } else { 113 | debug( 114 | `Removing ${searchResult} from rawSearchResults because it is a directory` 115 | ) 116 | } 117 | } 118 | 119 | // Calculate the root directory for the artifact using the search paths that were utilized 120 | const searchPaths: string[] = globCreationResult.getSearchPaths() 121 | 122 | if (searchPaths.length > 1) { 123 | info( 124 | `Multiple search paths detected. Calculating the least common ancestor of all paths` 125 | ) 126 | const lcaSearchPath = getMultiPathLCA(searchPaths) 127 | info( 128 | `The least common ancestor is ${lcaSearchPath}. This will be the root directory of the artifact` 129 | ) 130 | 131 | return { 132 | filesToUpload: searchResults, 133 | rootDirectory: lcaSearchPath 134 | } 135 | } 136 | 137 | /* 138 | Special case for a single file artifact that is uploaded without a directory or wildcard pattern. The directory structure is 139 | not preserved and the root directory will be the single files parent directory 140 | */ 141 | if (searchResults.length === 1 && searchPaths[0] === searchResults[0]) { 142 | return { 143 | filesToUpload: searchResults, 144 | rootDirectory: dirname(searchResults[0]) 145 | } 146 | } 147 | 148 | return { 149 | filesToUpload: searchResults, 150 | rootDirectory: searchPaths[0] 151 | } 152 | } -------------------------------------------------------------------------------- /src/upload-inputs.ts: -------------------------------------------------------------------------------- 1 | import {NoFileOptions} from './constants' 2 | 3 | export interface UploadInputs { 4 | /** 5 | * The name of the artifact that will be uploaded 6 | */ 7 | artifactName: string 8 | 9 | /** 10 | * The search path used to describe what to upload as part of the artifact 11 | */ 12 | searchPath: string 13 | 14 | /** 15 | * The desired behavior if no files are found with the provided search path 16 | */ 17 | ifNoFilesFound: NoFileOptions 18 | 19 | /** 20 | * Duration after which artifact will expire in days 21 | */ 22 | retentionDays: number 23 | } -------------------------------------------------------------------------------- /src/uploadArtifact.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import {create, UploadOptions} from '@actions/artifact'; 3 | import {findFilesToUpload} from './search'; 4 | import {getInputs} from './input-helper'; 5 | import {NoFileOptions} from './constants'; 6 | 7 | export async function uploadArtifact() { 8 | try { 9 | const inputs = getInputs() 10 | const searchResult = await findFilesToUpload(inputs.searchPath) 11 | 12 | if (searchResult.filesToUpload.length === 0) { 13 | // No files were found, different use cases warrant different types of behavior if nothing is found 14 | switch (inputs.ifNoFilesFound) { 15 | case NoFileOptions.warn: { 16 | core.warning( 17 | `No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.` 18 | ) 19 | break 20 | } 21 | case NoFileOptions.error: { 22 | core.setFailed( 23 | `No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.` 24 | ) 25 | break 26 | } 27 | case NoFileOptions.ignore: { 28 | core.info( 29 | `No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.` 30 | ) 31 | break 32 | } 33 | } 34 | } else { 35 | const s = searchResult.filesToUpload.length === 1 ? '' : 's' 36 | core.info( 37 | `With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded` 38 | ) 39 | core.debug(`Root artifact directory is ${searchResult.rootDirectory}`) 40 | 41 | if (searchResult.filesToUpload.length > 10000) { 42 | core.warning( 43 | `There are over 10,000 files in this artifact, consider create an archive before upload to improve the upload performance.` 44 | ) 45 | } 46 | 47 | const artifactClient = create() 48 | const options: UploadOptions = { 49 | continueOnError: false 50 | } 51 | if (inputs.retentionDays) { 52 | options.retentionDays = inputs.retentionDays 53 | } 54 | 55 | const uploadResponse = await artifactClient.uploadArtifact( 56 | inputs.artifactName, 57 | searchResult.filesToUpload, 58 | searchResult.rootDirectory, 59 | options 60 | ) 61 | 62 | if (uploadResponse.failedItems.length > 0) { 63 | core.setFailed( 64 | `An error was encountered when uploading ${uploadResponse.artifactName}. There were ${uploadResponse.failedItems.length} items that failed to upload.` 65 | ) 66 | } else { 67 | core.info( 68 | `Artifact ${uploadResponse.artifactName} has been successfully uploaded!` 69 | ) 70 | } 71 | } 72 | } catch (err) { 73 | core.setFailed(err.message) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | "noImplicitUseStrict": false, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "exclude": ["node_modules", "**/*.test.ts","__tests__","__mocks__"] 14 | } --------------------------------------------------------------------------------