diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..96c2e0d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Needed for publishing of examples, build worker defaults to core.autocrlf=input. +* text eol=autocrlf + +*.mof text eol=crlf +*.sh text eol=lf +*.svg eol=lf + +# Ensure any exe files are treated as binary +*.exe binary +*.jpg binary +*.xl* binary +*.pfx binary +*.png binary +*.dll binary +*.so binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..beae856d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,50 @@ +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, +# @global-owner1 and @global-owner2 will be requested for +# review when someone opens a pull request. +* @PSModule/framework-maintainers + +# Order is important; the last matching pattern takes the most +# precedence. When someone opens a pull request that only +# modifies JS files, only @js-owner and not the global +# owner(s) will be requested for a review. +# *.js @js-owner + +# You can also use email addresses if you prefer. They'll be +# used to look up users just like we do for commit author +# emails. +# *.go docs@example.com + +# Teams can be specified as code owners as well. Teams should +# be identified in the format @org/team-name. Teams must have +# explicit write access to the repository. In this example, +# the octocats team in the octo-org organization owns all .txt files. +# *.txt @octo-org/octocats + +# In this example, @doctocat owns any files in the build/logs +# directory at the root of the repository and any of its +# subdirectories. +# /build/logs/ @doctocat + +# The `docs/*` pattern will match files like +# `docs/getting-started.md` but not further nested files like +# `docs/build-app/troubleshooting.md`. +# docs/* docs@example.com + +# In this example, @octocat owns any file in an apps directory +# anywhere in your repository. +# apps/ @octocat + +# In this example, @doctocat owns any file in the `/docs` +# directory in the root of your repository and any of its +# subdirectories. +# /docs/ @doctocat + +# In this example, @octocat owns any file in the `/apps` +# directory in the root of your repository except for the `/apps/github` +# subdirectory, as its owners are left empty. +# /apps/ @octocat +# /apps/github diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f57e1e95 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: github-actions # See documentation for possible values + directory: / # Location of package manifests + schedule: + interval: weekly diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml new file mode 100644 index 00000000..8e719c26 --- /dev/null +++ b/.github/linters/.markdown-lint.yml @@ -0,0 +1,25 @@ +########################### +## Markdown Linter rules ## +########################### + +# Linter rules doc: +# - https://github.com/DavidAnson/markdownlint + +############### +# Rules by id # +############### +MD004: false # Unordered list style +MD007: + indent: 2 # Unordered list indentation +MD013: + line_length: 808 # Line length +MD026: + punctuation: '.,;:!。,;:' # List of not allowed +MD029: false # Ordered list item prefix +MD033: false # Allow inline HTML +MD036: false # Emphasis used instead of a heading + +################# +# Rules by tags # +################# +blank_lines: false # Error on blank lines diff --git a/.github/linters/.powershell-psscriptanalyzer.psd1 b/.github/linters/.powershell-psscriptanalyzer.psd1 new file mode 100644 index 00000000..40d11d60 --- /dev/null +++ b/.github/linters/.powershell-psscriptanalyzer.psd1 @@ -0,0 +1,18 @@ +#Documentation: https://github.com/PowerShell/PSScriptAnalyzer/blob/master/docs/Cmdlets/Invoke-ScriptAnalyzer.md#-settings +@{ + #CustomRulePath='path\to\CustomRuleModule.psm1' + #RecurseCustomRulePath='path\of\customrules' + #Severity = @( + # 'Error' + # 'Warning' + #) + #IncludeDefaultRules=${true} + ExcludeRules = @( + 'PSMissingModuleManifestField' + 'PSAvoidUsingWriteHost' + ) + #IncludeRules = @( + # 'PSAvoidUsingWriteHost', + # 'MyCustomRuleName' + #) +} diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..35c6ae86 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,18 @@ +# https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuring-automatically-generated-release-notes + +changelog: + exclude: + labels: + - NoRelease + categories: + - title: 🌟 Breaking Changes + labels: + - Major + - Breaking + - title: 🚀 New Features + labels: + - Minor + - Feature + - title: Other Changes + labels: + - '*' diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml index 6ff2f13e..3c834225 100644 --- a/.github/workflows/Action-Test.yml +++ b/.github/workflows/Action-Test.yml @@ -1,32 +1,57 @@ -name: Action-Test - -run-name: "Action-Test - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" - -on: - workflow_dispatch: - pull_request: - schedule: - - cron: '0 0 * * *' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: read - -jobs: - ActionTestBasic: - name: Action-Test - [Basic] - runs-on: ubuntu-latest - steps: - # Need to check out as part of the test, as its a local action - - name: Checkout repo - uses: actions/checkout@v4 - - - name: Action-Test - uses: ./ - with: - working-directory: ./tests - subject: PSModule +name: Action-Test + +run-name: "Action-Test - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" + +on: + workflow_dispatch: + pull_request: + schedule: + - cron: '0 0 * * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + ActionTest1Simple: + name: Action-Test - [1-Simple] + runs-on: ubuntu-latest + steps: + # Need to check out as part of the test, as its a local action + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Action-Test [1-Simple] + uses: ./ + with: + Path: tests/1-Simple + + ActionTest2Standard: + name: Action-Test - [2-Standard] + runs-on: ubuntu-latest + steps: + # Need to check out as part of the test, as its a local action + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Action-Test [2-Standard] + uses: ./ + with: + Path: tests/2-Standard + + ActionTestAdvanced: + name: Action-Test - [3-Advanced] + runs-on: ubuntu-latest + steps: + # Need to check out as part of the test, as its a local action + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Action-Test [3-Advanced] + uses: ./ + with: + Path: tests/3-Advanced/Pester.Configuration.ps1 diff --git a/.github/workflows/Auto-Configure.yml b/.github/workflows/Auto-Configure.yml deleted file mode 100644 index e2321e42..00000000 --- a/.github/workflows/Auto-Configure.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Auto-Configure - -run-name: "Auto-Configure - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" - -on: - pull_request_target: - branches: - - main - types: - - closed - - opened - - reopened - - synchronize - - labeled - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: write # Required to create releases - pull-requests: write # Required to create comments on the PRs - -jobs: - Auto-Configure: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Auto-Configure - uses: PSModule/Auto-Configure@v1 - env: - GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/Auto-Document.yml b/.github/workflows/Auto-Document.yml deleted file mode 100644 index 6a62053a..00000000 --- a/.github/workflows/Auto-Document.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Auto-Document - -run-name: "Auto-Document - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" - -on: - pull_request_target: - branches: - - main - types: - - opened - - reopened - - synchronize - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: write # Required to push to the branch - -jobs: - Auto-Document: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Auto-Document - uses: PSModule/Auto-Document@v1 - env: - GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/Auto-Release.yml b/.github/workflows/Auto-Release.yml index d6c477b7..1a580b87 100644 --- a/.github/workflows/Auto-Release.yml +++ b/.github/workflows/Auto-Release.yml @@ -1,34 +1,34 @@ -name: Auto-Release - -run-name: "Auto-Release - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" - -on: - pull_request_target: - branches: - - main - types: - - closed - - opened - - reopened - - synchronize - - labeled - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: write # Required to create releases - pull-requests: write # Required to create comments on the PRs - -jobs: - Auto-Release: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Auto-Release - uses: PSModule/Auto-Release@v1 - env: - GITHUB_TOKEN: ${{ github.token }} +name: Auto-Release + +run-name: "Auto-Release - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" + +on: + pull_request_target: + branches: + - main + types: + - closed + - opened + - reopened + - synchronize + - labeled + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write # Required to create releases + pull-requests: write # Required to create comments on the PRs + +jobs: + Auto-Release: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Auto-Release + uses: PSModule/Auto-Release@v1 + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/Linter.yml b/.github/workflows/Linter.yml index 187c17e6..bb47b678 100644 --- a/.github/workflows/Linter.yml +++ b/.github/workflows/Linter.yml @@ -1,29 +1,33 @@ -name: Linter - -run-name: "Linter - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" - -on: [pull_request] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - packages: read - statuses: write - -jobs: - Lint: - name: Lint code base - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Lint code base - uses: super-linter/super-linter/slim@latest - env: - GITHUB_TOKEN: ${{ github.token }} +name: Linter + +run-name: "Linter - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" + +on: [pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: read + statuses: write + +jobs: + Lint: + name: Lint code base + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Lint code base + uses: super-linter/super-linter/slim@latest + env: + GITHUB_TOKEN: ${{ github.token }} + VALIDATE_MARKDOWN_PRETTIER: false + VALIDATE_JSON_PRETTIER: false + VALIDATE_YAML_PRETTIER: false + VALIDATE_JSCPD: false diff --git a/README.md b/README.md index 3e74c20c..05abdade 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,271 @@ -# Template-action +# Invoke-Pester + +This GitHub Action runs [Pester](https://pester.dev) tests in PowerShell, producing code coverage and test result artifacts. It automates many tasks to streamline continuous integration for PowerShell projects: + +- Installation and import of required modules (Pester, PSScriptAnalyzer). +- Automatic merging of default configuration, test suite configuration, and direct inputs into a final Pester configuration. +- Optional uploading of test results and coverage reports. +- Clear step summary in GitHub’s job logs. + +## Introduction & Scope + +**Invoke-Pester** is designed to: +- **Execute all Pester tests** in your repository, with optional container-based test organization. +- **Collect code coverage** metrics, if desired. +- **Summarize test results** with a neat table in the workflow’s step summary. +- **Upload artifacts** (e.g. coverage reports, test results) to GitHub for later inspection. +- **Allow flexible configuration** through a layered approach—defaults, repository-level config, and direct inputs. + +By default, it tries to “just work” for the majority of scenarios. Advanced users can configure everything from coverage thresholds to skipping slow tests. + +## Configuration Hierarchy + +The action’s behavior is controlled by **layered configuration**: + +1. **Default Config** + Packaged with the action (in `Pester.Configuration.ps1` if provided). Sets base paths, coverage toggles, artifact names, etc. + +2. **Test Suite Config** + If your test suite contains a Pester config file (e.g., `MyTests.Configuration.psd1` or `Pester.Configuration.ps1`), the action loads and merges those settings on top of the defaults. + +3. **Direct Inputs** + Finally, any inputs specified under the `with:` clause in your GitHub Action workflow override both the default and test suite config. + > *Example:* If you specify `CodeCoverage_Enabled: true` here, it will enable coverage even if the test suite config says otherwise. + +This **“last-write-wins”** strategy means you can set global defaults while retaining the flexibility to override them at the action level. + + +## How This Action Processes Your Tests + +1. **Prerequisite Setup** + - Installs required PowerShell modules (Pester, PSScriptAnalyzer) if they’re not present. + - Imports the modules so the testing framework is ready to use. + +2. **Loading Inputs and Configuration** + - Reads all GitHub Action inputs from `action.yml` environment variables. + - (Optional) Loads a base config file included with the action (e.g., `Pester.Configuration.ps1` in the action folder). + - If `Path` points to a location with a Pester configuration file, merges that config. + - Finally, merges any direct inputs provided in your workflow. + - The result is a **final Pester configuration** that determines what tests to run and how to run them. + +3. **Building the Final Pester Configuration** + - Collects all merged settings into a single configuration object. + - If no “containers” (advanced Pester 5 grouping) are explicitly defined, it attempts to discover them automatically (files matching `*.Container.*`). + +4. **Running the Tests** + - Calls [`Invoke-Pester`](https://pester.dev/docs/commands/Invoke-Pester) using that final configuration. + - **Discovery Phase**: Finds test files/containers. + - **Execution Phase**: Runs tests, logs pass/fail/skipped/inconclusive. + - **Results Gathering**: Aggregates outcomes into a final test object. + +5. **Generating Reports (Optional)** + - **Test Results** (e.g., NUnit/XML) if `TestResult_Enabled` is `true`. The file is saved to `TestResult_OutputPath`. + - **Code Coverage** if `CodeCoverage_Enabled` is `true`. Saves coverage data (Cobertura, JaCoCo, etc.) to `CodeCoverage_OutputPath`. + - These reports can automatically be uploaded as workflow artifacts. + +6. **Summary in GitHub** + - A step summary is generated, showing how many tests passed/failed/skipped, plus coverage info. + - If containers are in use, each container’s results appear in a collapsible section. + +7. **Publishing Outputs** + - Key metrics (e.g., `Result`, `FailedCount`, `Duration`) are encoded in JSON and published as outputs. + - Subsequent steps can parse these to decide whether to fail the build, open an issue, or notify a channel. + +8. **Exit Code** + - By default, returns a non-zero exit code if any tests fail (unless you override with `Run_Exit: false` or `Run_Throw: false`). + - This ensures the GitHub job is marked as failed if your tests do not pass. + + +## Failure Handling + +- **No Immediate Fail on First Test Error**: + The entire suite runs, capturing all failures before deciding on pass/fail. +- **Allowed Failures / Coverage Threshold**: + You can configure if any test failure leads to a fail, or whether certain coverage levels must be met. +- **Default Behavior**: + If any test fails, the job fails at the end (exit code != 0). You can change this via `Run_Exit` or `Run_Throw`. + + +## Artifact Management + +- **Test Result Artifacts** + - By default, if `TestResult_Enabled` is true, the action saves a test result file (XML/JSON) to `TestResult_OutputPath` and uploads it with GitHub’s `actions/upload-artifact`. +- **Coverage Report Artifacts** + - If `CodeCoverage_Enabled` is true, a coverage file (Cobertura, JaCoCo, etc.) is generated and uploaded similarly. +- **Naming & Paths** + - You can override default filenames or directories in your config or direct inputs. +- **Logs & Extras** + - Generally, the action only uploads essential coverage and test result files, though you can adapt it to collect additional logs if needed. + +## Step Summary & Coverage Reporting + +- **Detailed Markdown Summary**: + Displays overall test results (passed, failed, skipped) and coverage in a table. +- **Collapsible Breakdown**: + Each container or test file can be expanded for deeper inspection. +- **Always Visible**: + Even if the action fails, the summary is posted so you can quickly see why. + +## Potential Pitfalls + +- If your tests are in a subfolder and `Path` or `Run_Path` isn’t updated, you might discover zero tests. +- Code coverage can differ between breakpoint-based (default) and profiler-based methods—choose which suits your environment (`CodeCoverage_UseBreakpoints`). +- Containers are optional in Pester 5. If you rely on them but name them incorrectly, Pester might skip them. + +## Automation Notes + +- **Threshold Enforcement**: You can parse `CoveragePercent` from the outputs in a subsequent step and fail the build if coverage is below X%. +- **Automatic Notifications**: Use the published JSON outputs (e.g., `Failed`) to highlight failing tests in Slack or Teams. +- **Versioning & Releases**: If `FailedCount` is zero, you could trigger a deployment or release pipeline automatically. -A template repository for GitHub Actions ## Usage +Below is a typical usage example. (Subsequent sections list *all* available inputs and outputs.) + +```yaml +name: Pester Tests + +on: + push: + +jobs: + test-pester: + runs-on: ubuntu-latest + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Run Pester Tests + uses: PSModule/Invoke-Pester@v1 + with: + Path: './tests' + CodeCoverage_Enabled: 'true' + TestResult_Enabled: 'true' + TestResult_TestSuiteName: 'IntegrationTests' + # Configure additional inputs, e.g. Run_Throw, Run_Exit, etc. + + # If coverage & results are enabled, the action automatically uploads them as artifacts. + # The step exit code will be non-zero if any tests fail, unless overridden. +``` + ### Inputs -### Secrets +All are **optional** unless otherwise noted. For more info on the configuration options, see the [Pester documentation](https://pester.dev/docs/usage/configuration). + +| **Input** | **Description** | **Default** | +|--------------------------------------|--------------------------------------------------------------------------------------------------------|---------------------------------| +| `Path` | Path to where tests are located or a configuration file. | `${{ github.workspace }}/tests` | +| `Run_Path` | Directories/files to be searched for tests. | *(none)* | +| `Run_ExcludePath` | Directories/files to exclude from the run. | *(none)* | +| `Run_ScriptBlock` | ScriptBlocks containing tests to be executed. | *(none)* | +| `Run_Container` | ContainerInfo objects containing tests to be executed. | *(none)* | +| `Run_TestExtension` | Filter used to identify test files (e.g. `.Tests.ps1`). | *(none)* | +| `Run_Exit` | Whether to exit with a non-zero exit code on failure. | *(none)* | +| `Run_Throw` | Whether to throw an exception on test failure. | *(none)* | +| `Run_PassThru` | Return result object to pipeline after finishing the test run. | *(none)* | +| `Run_SkipRun` | Discovery only, skip actual test run. | *(none)* | +| `Run_SkipRemainingOnFailure` | Skips remaining tests after the first failure. Options: `None`, `Run`, `Container`, `Block`. | *(none)* | +| `Filter_Tag` | Tags of Describe/Context/It blocks to run. | *(none)* | +| `Filter_ExcludeTag` | Tags of Describe/Context/It blocks to exclude. | *(none)* | +| `Filter_Line` | Filter by file + scriptblock start line (e.g. `C:\tests\file1.Tests.ps1:37`). | *(none)* | +| `Filter_ExcludeLine` | Exclude by file + scriptblock start line. Precedence over `Filter_Line`. | *(none)* | +| `Filter_FullName` | Full name of a test with wildcards, joined by dot. E.g. `*.describe Get-Item.test1` | *(none)* | +| `CodeCoverage_Enabled` | Enable code coverage. | *(none)* | +| `CodeCoverage_OutputFormat` | Format for the coverage report. Possible values: `JaCoCo`, `CoverageGutters`, `Cobertura`. | *(none)* | +| `CodeCoverage_OutputPath` | Where to save the code coverage report (relative to the current dir). | *(none)* | +| `CodeCoverage_OutputEncoding` | Encoding of the coverage file. | *(none)* | +| `CodeCoverage_Path` | Files/directories to measure coverage on (by default, reuses `Path` from the general settings). | *(none)* | +| `CodeCoverage_ExcludeTests` | Exclude tests themselves from coverage. | *(none)* | +| `CodeCoverage_RecursePaths` | Recurse through coverage directories. | *(none)* | +| `CodeCoverage_CoveragePercentTarget` | Desired minimum coverage percentage. | *(none)* | +| `CodeCoverage_UseBreakpoints` | **Experimental**: When `false`, use a Profiler-based tracer instead of breakpoints. | *(none)* | +| `CodeCoverage_SingleHitBreakpoints` | Remove breakpoints after first hit. | *(none)* | +| `TestResult_Enabled` | Enable test-result output (e.g. NUnitXml, JUnitXml). | *(none)* | +| `TestResult_OutputFormat` | Possible values: `NUnitXml`, `NUnit2.5`, `NUnit3`, `JUnitXml`. | *(none)* | +| `TestResult_OutputPath` | Where to save the test-result report (relative path). | *(none)* | +| `TestResult_OutputEncoding` | Encoding of the test-result file. | *(none)* | +| `TestResult_TestSuiteName` | Name used for the root `test-suite` element in the result file. | *(none)* | +| `Should_ErrorAction` | Controls if `Should` throws on error. Use `Stop` to throw, or `Continue` to fail at the end. | *(none)* | +| `Debug_ShowFullErrors` | Show Pester internal stack on errors. (Deprecated – overrides `Output.StackTraceVerbosity` to `Full`). | *(none)* | +| `Debug_WriteDebugMessages` | Write debug messages to screen. | *(none)* | +| `Debug_WriteDebugMessagesFrom` | Filter debug messages by source. Wildcards allowed. | *(none)* | +| `Debug_ShowNavigationMarkers` | Write paths after every block/test for easy navigation in Visual Studio Code. | *(none)* | +| `Debug_ReturnRawResultObject` | Returns an unfiltered result object, for development only. | *(none)* | +| `Output_Verbosity` | Verbosity: `None`, `Normal`, `Detailed`, `Diagnostic`. | *(none)* | +| `Output_StackTraceVerbosity` | Stacktrace detail: `None`, `FirstLine`, `Filtered`, `Full`. | *(none)* | +| `Output_CIFormat` | CI format of error output: `None`, `Auto`, `AzureDevops`, `GithubActions`. | *(none)* | +| `Output_CILogLevel` | CI log level: `Error` or `Warning`. | *(none)* | +| `Output_RenderMode` | How to render console output: `Auto`, `Ansi`, `ConsoleColor`, `Plaintext`. | *(none)* | +| `TestDrive_Enabled` | Enable `TestDrive`. | *(none)* | +| `TestRegistry_Enabled` | Enable `TestRegistry`. | *(none)* | +| `Debug` | Enable debug mode (`true`/`false`). When `true`, uses `PSModule/Debug@v0`. | `false` | + +No secrets are directly required by this Action. ### Outputs -### Example +After the test run completes, these outputs become available. They are all JSON-encoded strings, so you can parse them in subsequent steps if needed: -```yaml -Example here -``` +| **Output** | **Description** | +|--------------------------|------------------------------------------------------| +| `Containers` | Containers object used during the test. | +| `Result` | Whether the tests passed (`Passed`, `Failed`, etc.). | +| `FailedCount` | Number of failed tests. | +| `FailedBlocksCount` | Number of failed blocks. | +| `FailedContainersCount` | Number of failed containers. | +| `PassedCount` | Number of passed tests. | +| `SkippedCount` | Number of skipped tests. | +| `InconclusiveCount` | Number of inconclusive tests. | +| `NotRunCount` | Number of tests not run. | +| `TotalCount` | Total number of tests. | +| `Duration` | Duration of the test run. | +| `Executed` | Number of tests actually executed. | +| `ExecutedAt` | DateTime of the test run. | +| `Version` | Pester version. | +| `PSVersion` | PowerShell version. | +| `PSBoundParameters` | The final set of parameters used to run the tests. | +| `Plugins` | Plugins used during the run. | +| `PluginConfiguration` | Configuration for those plugins. | +| `PluginData` | Data from those plugins. | +| `Configuration` | The merged final Pester configuration used. | +| `DiscoveryDuration` | Discovery-phase duration. | +| `UserDuration` | Duration of user code execution. | +| `FrameworkDuration` | Duration of framework code execution. | +| `Failed` | Info on failed tests. | +| `FailedBlocks` | Info on failed blocks. | +| `FailedContainers` | Info on failed containers. | +| `Passed` | Info on passed tests. | +| `Skipped` | Info on skipped tests. | +| `Inconclusive` | Info on inconclusive tests. | +| `NotRun` | Info on tests not run. | +| `Tests` | All discovered tests. | +| `CodeCoverage` | Code coverage report object. | +| `TestResultEnabled` | `true`/`false` based on `TestResult_Enabled`. | +| `TestResultOutputPath` | Path to the test result file. | +| `TestSuiteName` | Name of the test suite. | +| `CodeCoverageEnabled` | `true`/`false` based on `CodeCoverage_Enabled`. | +| `CodeCoverageOutputPath` | Where the coverage report was saved. | + + +### Tips & Notes + +- To **skip coverage** or **test result uploads**, set `CodeCoverage_Enabled: false` or `TestResult_Enabled: false`. +- If you do **not** want a failing test to cause the step to fail, set `Run_Exit: false` and `Run_Throw: false`. +- For deeper debug info, set `Debug: 'true'` (which uses the [PSModule/Debug@v0](https://github.com/PSModule/Debug) action). +- If your tests require a **custom Pester config**, place it in your repository and point `Path` or `Run_Path` to it. The action merges that file with defaults. + +## Contributing + +1. Open a pull request with your proposed changes (bugfixes, improvements, new features). +2. Test your branch in a real or mock workflow if possible to confirm it behaves as intended. +3. We welcome any ideas for streamlining test runs, coverage generation, or other enhancements. + +## Conclusion + +The **Invoke-Pester** GitHub Action streamlines automated PowerShell testing in CI/CD by merging multiple configuration layers, running Pester tests, +collecting coverage, generating artifacts, and neatly summarizing results. It helps maintain a robust CI environment for PowerShell projects of all +sizes. If you have questions or want to contribute, feel free to open an issue or pull request. + +Happy pestering! diff --git a/action.yml b/action.yml index 0a56ffb2..4f1900dd 100644 --- a/action.yml +++ b/action.yml @@ -1,27 +1,366 @@ -name: '{{ NAME }}' -description: '{{ DESCRIPTION }}' -author: PSModule -branding: - icon: upload-cloud - color: white - -inputs: - working-directory: - description: The working directory where Terraform will be executed - required: false - subject: - description: The subject to greet - required: false - default: World - -runs: - using: composite - steps: - - name: '{{ NAME }}' - shell: pwsh - working-directory: ${{ inputs.working-directory }} - env: - GITHUB_ACTION_INPUT_subject: ${{ inputs.subject }} - run: | - # '{{ NAME }}' - . "${{ github.action_path }}\scripts\main.ps1" -Verbose +name: Invoke-Pester (by PSModule) +description: Runs test, using Pester and PowerShell. +author: PSModule +branding: + icon: check-square + color: gray-dark + +inputs: + Path: + description: | + Path to where tests are located or a configuration file. + required: false + default: ${{ github.workspace }}/tests + Run_Path: + description: | + Directories to be searched for tests, paths directly to test files, or combination of both. + required: false + Run_ExcludePath: + description: | + Directories or files to be excluded from the run. + required: false + Run_ScriptBlock: + description: | + ScriptBlocks containing tests to be executed. + required: false + Run_Container: + description: | + ContainerInfo objects containing tests to be executed. + required: false + Run_TestExtension: + description: | + Filter used to identify test files. + required: false + Run_Exit: + description: | + Exit with non-zero exit code when the test run fails. Exit code is always set to `$LASTEXITCODE` even when this option is `$false`. + When used together with Throw, throwing an exception is preferred. + required: false + Run_Throw: + description: | + Throw an exception when test run fails. When used together with Exit, throwing an exception is preferred. + required: false + Run_PassThru: + description: | + Return result object to the pipeline after finishing the test run. + required: false + Run_SkipRun: + description: | + Runs the discovery phase but skips run. Use it with PassThru to get object populated with all tests. + required: false + Run_SkipRemainingOnFailure: + description: | + Skips remaining tests after failure for selected scope, options are None, Run, Container and Block. + required: false + Filter_Tag: + description: | + Tags of Describe, Context or It to be run. + required: false + Filter_ExcludeTag: + description: | + Tags of Describe, Context or It to be excluded from the run. + required: false + Filter_Line: + description: | + Filter by file and scriptblock start line, useful to run parsed tests programmatically to avoid problems with expanded names. + Example: 'C:\tests\file1.Tests.ps1:37' + required: false + Filter_ExcludeLine: + description: | + Exclude by file and scriptblock start line, takes precedence over Line. + required: false + Filter_FullName: + description: | + Full name of test with -like wildcards, joined by dot. Example: '*.describe Get-Item.test1' + required: false + CodeCoverage_Enabled: + description: | + Enable CodeCoverage. + required: false + CodeCoverage_OutputFormat: + description: | + Format to use for code coverage report. Possible values: JaCoCo, CoverageGutters, Cobertura + required: false + CodeCoverage_OutputPath: + description: | + Path relative to the current directory where code coverage report is saved. + required: false + CodeCoverage_OutputEncoding: + description: | + Encoding of the output file. + required: false + CodeCoverage_Path: + description: | + Directories or files to be used for code coverage, by default the Path(s) from general settings are used, unless overridden here. + required: false + CodeCoverage_ExcludeTests: + description: | + Exclude tests from code coverage. This uses the TestFilter from general configuration. + required: false + CodeCoverage_RecursePaths: + description: | + Will recurse through directories in the Path option. + required: false + CodeCoverage_CoveragePercentTarget: + description: | + Target percent of code coverage that you want to achieve. + required: false + CodeCoverage_UseBreakpoints: + description: | + EXPERIMENTAL: When false, use Profiler based tracer to do CodeCoverage instead of using breakpoints. + required: false + CodeCoverage_SingleHitBreakpoints: + description: | + Remove breakpoint when it is hit. + required: false + TestResult_Enabled: + description: | + Enable TestResult. + required: false + TestResult_OutputFormat: + description: | + Format to use for test result report. Possible values: NUnitXml, NUnit2.5, NUnit3 or JUnitXml + required: false + TestResult_OutputPath: + description: | + Path relative to the current directory where test result report is saved. + required: false + TestResult_OutputEncoding: + description: | + Encoding of the output file. + required: false + TestResult_TestSuiteName: + description: | + Set the name assigned to the root 'test-suite' element. + required: false + Should_ErrorAction: + description: | + Controls if Should throws on error. Use 'Stop' to throw on error, or 'Continue' to fail at the end of the test. + required: false + Debug_ShowFullErrors: + description: | + Show full errors including Pester internal stack. This property is deprecated, and if set to true it will override Output.StackTraceVerbosity to 'Full'. + required: false + Debug_WriteDebugMessages: + description: | + Write Debug messages to screen. + required: false + Debug_WriteDebugMessagesFrom: + description: | + Write Debug messages from a given source, WriteDebugMessages must be set to true for this to work. + You can use like wildcards to get messages from multiple sources, as well as * to get everything. + required: false + Debug_ShowNavigationMarkers: + description: | + Write paths after every block and test, for easy navigation in VSCode. + required: false + Debug_ReturnRawResultObject: + description: | + Returns unfiltered result object, this is for development only. Do not rely on this object for additional properties, + non-public properties will be renamed without previous notice. + required: false + Output_Verbosity: + description: | + The verbosity of output, options are None, Normal, Detailed and Diagnostic. + required: false + Output_StackTraceVerbosity: + description: | + The verbosity of stacktrace output, options are None, FirstLine, Filtered and Full. + required: false + Output_CIFormat: + description: | + The CI format of error output in build logs, options are None, Auto, AzureDevops and GithubActions. + required: false + Output_CILogLevel: + description: | + The CI log level in build logs, options are Error and Warning. + required: false + Output_RenderMode: + description: | + The mode used to render console output, options are Auto, Ansi, ConsoleColor and Plaintext. + required: false + TestDrive_Enabled: + description: | + Enable TestDrive. + required: false + TestRegistry_Enabled: + description: | + Enable TestRegistry. + required: false + Debug: + description: | + Enable debug mode. + required: false + default: 'false' +outputs: + Containers: + description: Containers object used during the test. + value: ${{ fromJSON(steps.test.outputs.result).Containers }} + Result: + description: If the tests passed. + value: ${{ fromJSON(steps.test.outputs.result).Result }} + FailedCount: + description: Number of failed tests. + value: ${{ fromJSON(steps.test.outputs.result).FailedCount }} + FailedBlocksCount: + description: Number of failed blocks. + value: ${{ fromJSON(steps.test.outputs.result).FailedBlocksCount }} + FailedContainersCount: + description: Number of failed containers. + value: ${{ fromJSON(steps.test.outputs.result).FailedContainersCount }} + PassedCount: + description: Number of passed tests. + value: ${{ fromJSON(steps.test.outputs.result).PassedCount }} + SkippedCount: + description: Number of skipped tests. + value: ${{ fromJSON(steps.test.outputs.result).SkippedCount }} + InconclusiveCount: + description: Number of inconclusive tests. + value: ${{ fromJSON(steps.test.outputs.result).InconclusiveCount }} + NotRunCount: + description: Number of tests that were not run. + value: ${{ fromJSON(steps.test.outputs.result).NotRunCount }} + TotalCount: + description: Total number of tests. + value: ${{ fromJSON(steps.test.outputs.result).TotalCount }} + Duration: + description: Duration of the test run. + value: ${{ fromJSON(steps.test.outputs.result).Duration }} + Executed: + description: Number of tests executed. + value: ${{ fromJSON(steps.test.outputs.result).Executed }} + ExecutedAt: + description: Time when the tests were executed. + value: ${{ fromJSON(steps.test.outputs.result).ExecutedAt }} + Version: + description: Version of Pester used. + value: ${{ fromJSON(steps.test.outputs.result).Version }} + PSVersion: + description: Version of PowerShell used. + value: ${{ fromJSON(steps.test.outputs.result).PSVersion }} + PSBoundParameters: + description: Parameters used to run the tests. + value: ${{ fromJSON(steps.test.outputs.result).PSBoundParameters }} + Plugins: + description: Plugins used during the test run. + value: ${{ fromJSON(steps.test.outputs.result).Plugins }} + PluginConfiguration: + description: Configuration of the plugins. + value: ${{ fromJSON(steps.test.outputs.result).PluginConfiguration }} + PluginData: + description: Data of the plugins. + value: ${{ fromJSON(steps.test.outputs.result).PluginData }} + Configuration: + description: Configuration used during the test run. + value: ${{ fromJSON(steps.test.outputs.result).Configuration }} + DiscoveryDuration: + description: Duration of the discovery phase. + value: ${{ fromJSON(steps.test.outputs.result).DiscoveryDuration }} + UserDuration: + description: Duration of the user code execution. + value: ${{ fromJSON(steps.test.outputs.result).UserDuration }} + FrameworkDuration: + description: Duration of the framework code execution. + value: ${{ fromJSON(steps.test.outputs.result).FrameworkDuration }} + Failed: + description: Failed tests. + value: ${{ fromJSON(steps.test.outputs.result).Failed }} + FailedBlocks: + description: Failed blocks. + value: ${{ fromJSON(steps.test.outputs.result).FailedBlocks }} + FailedContainers: + description: Failed containers. + value: ${{ fromJSON(steps.test.outputs.result).FailedContainers }} + Passed: + description: Passed tests. + value: ${{ fromJSON(steps.test.outputs.result).Passed }} + Skipped: + description: Skipped tests. + value: ${{ fromJSON(steps.test.outputs.result).Skipped }} + Inconclusive: + description: Inconclusive tests. + value: ${{ fromJSON(steps.test.outputs.result).Inconclusive }} + NotRun: + description: Tests that were not run. + value: ${{ fromJSON(steps.test.outputs.result).NotRun }} + Tests: + description: All tests. + value: ${{ fromJSON(steps.test.outputs.result).Tests }} + CodeCoverage: + description: Code coverage report. + value: ${{ fromJSON(steps.test.outputs.result).CodeCoverage }} + +runs: + using: composite + steps: + - uses: PSModule/Debug@v0 + if: ${{ inputs.Debug == 'true' }} + + - name: Invoke-Pester + uses: PSModule/GitHub-Script@v1 + id: test + continue-on-error: true + env: + GITHUB_ACTION_INPUT_Path: ${{ inputs.Path }} + GITHUB_ACTION_INPUT_Run_Path: ${{ inputs.Run_Path }} + GITHUB_ACTION_INPUT_Run_ExcludePath: ${{ inputs.Run_ExcludePath }} + GITHUB_ACTION_INPUT_Run_ScriptBlock: ${{ inputs.Run_ScriptBlock }} + GITHUB_ACTION_INPUT_Run_Container: ${{ inputs.Run_Container }} + GITHUB_ACTION_INPUT_Run_TestExtension: ${{ inputs.Run_TestExtension }} + GITHUB_ACTION_INPUT_Run_Exit: ${{ inputs.Run_Exit }} + GITHUB_ACTION_INPUT_Run_Throw: ${{ inputs.Run_Throw }} + GITHUB_ACTION_INPUT_Run_PassThru: ${{ inputs.Run_PassThru }} + GITHUB_ACTION_INPUT_Run_SkipRun: ${{ inputs.Run_SkipRun }} + GITHUB_ACTION_INPUT_Run_SkipRemainingOnFailure: ${{ inputs.Run_SkipRemainingOnFailure }} + GITHUB_ACTION_INPUT_Filter_Tag: ${{ inputs.Filter_Tag }} + GITHUB_ACTION_INPUT_Filter_ExcludeTag: ${{ inputs.Filter_ExcludeTag }} + GITHUB_ACTION_INPUT_Filter_Line: ${{ inputs.Filter_Line }} + GITHUB_ACTION_INPUT_Filter_ExcludeLine: ${{ inputs.Filter_ExcludeLine }} + GITHUB_ACTION_INPUT_Filter_FullName: ${{ inputs.Filter_FullName }} + GITHUB_ACTION_INPUT_CodeCoverage_Enabled: ${{ inputs.CodeCoverage_Enabled }} + GITHUB_ACTION_INPUT_CodeCoverage_OutputFormat: ${{ inputs.CodeCoverage_OutputFormat }} + GITHUB_ACTION_INPUT_CodeCoverage_OutputPath: ${{ inputs.CodeCoverage_OutputPath }} + GITHUB_ACTION_INPUT_CodeCoverage_OutputEncoding: ${{ inputs.CodeCoverage_OutputEncoding }} + GITHUB_ACTION_INPUT_CodeCoverage_Path: ${{ inputs.CodeCoverage_Path }} + GITHUB_ACTION_INPUT_CodeCoverage_ExcludeTests: ${{ inputs.CodeCoverage_ExcludeTests }} + GITHUB_ACTION_INPUT_CodeCoverage_RecursePaths: ${{ inputs.CodeCoverage_RecursePaths }} + GITHUB_ACTION_INPUT_CodeCoverage_CoveragePercentTarget: ${{ inputs.CodeCoverage_CoveragePercentTarget }} + GITHUB_ACTION_INPUT_CodeCoverage_UseBreakpoints: ${{ inputs.CodeCoverage_UseBreakpoints }} + GITHUB_ACTION_INPUT_CodeCoverage_SingleHitBreakpoints: ${{ inputs.CodeCoverage_SingleHitBreakpoints }} + GITHUB_ACTION_INPUT_TestResult_Enabled: ${{ inputs.TestResult_Enabled }} + GITHUB_ACTION_INPUT_TestResult_OutputFormat: ${{ inputs.TestResult_OutputFormat }} + GITHUB_ACTION_INPUT_TestResult_OutputPath: ${{ inputs.TestResult_OutputPath }} + GITHUB_ACTION_INPUT_TestResult_OutputEncoding: ${{ inputs.TestResult_OutputEncoding }} + GITHUB_ACTION_INPUT_TestResult_TestSuiteName: ${{ inputs.TestResult_TestSuiteName }} + GITHUB_ACTION_INPUT_Should_ErrorAction: ${{ inputs.Should_ErrorAction }} + GITHUB_ACTION_INPUT_Debug_ShowFullErrors: ${{ inputs.Debug_ShowFullErrors }} + GITHUB_ACTION_INPUT_Debug_WriteDebugMessages: ${{ inputs.Debug_WriteDebugMessages }} + GITHUB_ACTION_INPUT_Debug_WriteDebugMessagesFrom: ${{ inputs.Debug_WriteDebugMessagesFrom }} + GITHUB_ACTION_INPUT_Debug_ShowNavigationMarkers: ${{ inputs.Debug_ShowNavigationMarkers }} + GITHUB_ACTION_INPUT_Debug_ReturnRawResultObject: ${{ inputs.Debug_ReturnRawResultObject }} + GITHUB_ACTION_INPUT_Output_Verbosity: ${{ inputs.Output_Verbosity }} + GITHUB_ACTION_INPUT_Output_StackTraceVerbosity: ${{ inputs.Output_StackTraceVerbosity }} + GITHUB_ACTION_INPUT_Output_CIFormat: ${{ inputs.Output_CIFormat }} + GITHUB_ACTION_INPUT_Output_CILogLevel: ${{ inputs.Output_CILogLevel }} + GITHUB_ACTION_INPUT_Output_RenderMode: ${{ inputs.Output_RenderMode }} + GITHUB_ACTION_INPUT_TestDrive_Enabled: ${{ inputs.TestDrive_Enabled }} + GITHUB_ACTION_INPUT_TestRegistry_Enabled: ${{ inputs.TestRegistry_Enabled }} + with: + ShowOutput: true + Script: | + # Invoke-Pester + ${{ github.action_path }}\scripts\main.ps1 + + - name: Upload test results - [${{ fromJSON(steps.test.outputs.result).TestSuiteName }}-TestResults] + uses: actions/upload-artifact@v4 + if: ${{ fromJSON(steps.test.outputs.result).TestResultEnabled && (success() || failure()) }} + with: + name: ${{ fromJSON(steps.test.outputs.result).TestSuiteName }}-TestResults + path: ${{ fromJSON(steps.test.outputs.result).TestResultOutputPath }} + + - name: Upload code coverage report - [${{ fromJSON(steps.test.outputs.result).TestSuiteName }}-CodeCoverage] + uses: actions/upload-artifact@v4 + if: ${{ fromJSON(steps.test.outputs.result).CodeCoverageEnabled && (success() || failure()) }} + with: + name: ${{ fromJSON(steps.test.outputs.result).TestSuiteName }}-CodeCoverage + path: ${{ fromJSON(steps.test.outputs.result).CodeCoverageOutputPath }} diff --git a/scripts/Helpers.psm1 b/scripts/Helpers.psm1 new file mode 100644 index 00000000..41c93129 --- /dev/null +++ b/scripts/Helpers.psm1 @@ -0,0 +1,327 @@ +function Get-PesterContainer { + param( + [string] $Path + ) + + Get-ChildItem -Path $Path -Recurse -Filter *.Container.ps* | ForEach-Object { + $file = $_ + switch ($file.Extension) { + '.ps1' { + . $file + } + '.psd1' { + Import-PowerShellDataFile -Path $file + } + } + } +} + +function Get-PesterConfiguration { + param( + [string] $Path + ) + + Write-Output "Path: [$Path]" + $pathExists = Test-Path -Path $Path + if (-not $pathExists) { + throw "Test path does not exist: [$Path]" + } + $item = $Path | Get-Item + + if ($item.PSIsContainer) { + $file = Get-ChildItem -Path $Path -Filter *.Configuration.* + if ($file.Count -eq 0) { + Write-Verbose "No configuration files found in path: [$Path]" + return @{} + } + if ($file.Count -gt 1) { + throw "Multiple configuration files found in path: [$Path]" + } + } else { + $file = $item + } + + switch ($file.Extension) { + '.ps1' { + . $file + } + '.psd1' { + Import-PowerShellDataFile -Path $file + } + } +} + +filter Clear-PesterConfigurationEmptyValues { + [OutputType([Hashtable])] + [CmdletBinding()] + param ( + [Parameter( + Mandatory, + ValueFromPipeline, + ValueFromPipelineByPropertyName + )] + [hashtable] $Hashtable + ) + + $return = @{} + + foreach ($section in $Hashtable.Keys) { + $filteredProperties = @{} + + foreach ($property in $Hashtable[$section].Keys) { + $value = $Hashtable[$section][$property] + + # If the value isn't null or empty string, keep it. + if (-not [string]::IsNullOrEmpty($value)) { + $filteredProperties[$property] = $value + } + } + + $return[$section] = $filteredProperties + } + + return $return +} + +function Merge-Hashtable { + <# + .SYNOPSIS + Merge two hashtables, with the second hashtable overriding the first + + .DESCRIPTION + Merge two hashtables, with the second hashtable overriding the first + + .EXAMPLE + $Main = [ordered]@{ + Action = '' + Location = 'Main' + Name = 'Main' + Mode = 'Main' + } + $Override1 = [ordered]@{ + Action = '' + Location = '' + Name = 'Override1' + Mode = 'Override1' + } + $Override2 = [ordered]@{ + Action = '' + Location = '' + Name = 'Override1' + Mode = 'Override2' + } + Merge-Hashtables -Main $Main -Overrides $Override1, $Override2 + #> + [OutputType([Hashtable])] + [Alias('Merge-Hashtables')] + [CmdletBinding()] + param ( + # Main hashtable + [Parameter(Mandatory)] + [object] $Main, + + # Hashtable with overrides. + # Providing a list of overrides will apply them in order. + # Last write wins. + [Parameter(Mandatory)] + [object[]] $Overrides + ) + $Output = $Main.Clone() + foreach ($Override in $Overrides) { + foreach ($Key in $Override.Keys) { + if (($Output.Keys) -notcontains $Key) { + $Output.$Key = $Override.$Key + } + if (-not [string]::IsNullOrEmpty($Override.item($Key))) { + $Output.$Key = $Override.$Key + } + } + } + return $Output +} + +filter Format-TimeSpan { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [TimeSpan] $TimeSpan + ) + + #----- 1) Handle negative TimeSpan ----- + $isNegative = $TimeSpan.Ticks -lt 0 + if ($isNegative) { + $TimeSpan = New-TimeSpan -Ticks (-1 * $TimeSpan.Ticks) + } + + # Save original ticks for fractional math. + $originalTicks = $TimeSpan.Ticks + + #----- 2) Define constants ----- + [long] $ticks = $TimeSpan.Ticks + [long] $ticksInMillisecond = 10000 # 1 ms = 10,000 ticks + [long] $ticksInSecond = 10000000 # 1 s = 10,000,000 ticks + [long] $ticksInMinute = $ticksInSecond * 60 + [long] $ticksInHour = $ticksInMinute * 60 + [long] $ticksInDay = $ticksInHour * 24 + [long] $ticksInWeek = $ticksInDay * 7 + + # Approximate day-based constants for months & years + [double] $daysInMonth = 30.436875 + [double] $daysInYear = 365.2425 + [long] $ticksInMonth = [long]($daysInMonth * $ticksInDay) + [long] $ticksInYear = [long]($daysInYear * $ticksInDay) + + #----- 3) Extract units from largest to smallest ----- + $years = [math]::Floor($ticks / $ticksInYear) + $ticks %= $ticksInYear + $months = [math]::Floor($ticks / $ticksInMonth) + $ticks %= $ticksInMonth + $weeks = [math]::Floor($ticks / $ticksInWeek) + $ticks %= $ticksInWeek + $days = [math]::Floor($ticks / $ticksInDay) + $ticks %= $ticksInDay + $hours = [math]::Floor($ticks / $ticksInHour) + $ticks %= $ticksInHour + $minutes = [math]::Floor($ticks / $ticksInMinute) + $ticks %= $ticksInMinute + $seconds = [math]::Floor($ticks / $ticksInSecond) + $ticks %= $ticksInSecond + $milliseconds = [math]::Floor($ticks / $ticksInMillisecond) + $ticks %= $ticksInMillisecond + $microseconds = [math]::Floor($ticks / 10) + $ticks %= 10 + $nanoseconds = $ticks * 100 + + #----- 4) Build a list of components ----- + $components = [System.Collections.Generic.List[object]]::new() + $components.Add(@('Years', $years, 'y')) + $components.Add(@('Months', $months, 'mo')) + $components.Add(@('Weeks', $weeks, 'w')) + $components.Add(@('Days', $days, 'd')) + $components.Add(@('Hours', $hours, 'h')) + $components.Add(@('Minutes', $minutes, 'm')) + $components.Add(@('Seconds', $seconds, 's')) + $components.Add(@('Milliseconds', $milliseconds, 'ms')) + $components.Add(@('Microseconds', $microseconds, 'us')) + $components.Add(@('Nanoseconds', $nanoseconds, 'ns')) + + # Map each unit to a numeric rank (lower = more significant) + $unitRank = @{ + 'Years' = 1 + 'Months' = 2 + 'Weeks' = 3 + 'Days' = 4 + 'Hours' = 5 + 'Minutes' = 6 + 'Seconds' = 7 + 'Milliseconds' = 8 + 'Microseconds' = 9 + 'Nanoseconds' = 10 + } + # With no Precision parameter, allow all units (Nanoseconds rank is 10) + [int] $lowestUnitAllowed = 10 + + #----- 5) Adaptive rounding: Pick the first (highest) nonzero unit ----- + $highestUnitComponent = $null + foreach ($comp in $components) { + $unitName = $comp[0] + $value = $comp[1] + if ($unitRank[$unitName] -le $lowestUnitAllowed -and $value -ne 0) { + $highestUnitComponent = $comp + break + } + } + if (-not $highestUnitComponent) { + # If all components are zero, fall back to Nanoseconds. + $highestUnitComponent = $components[-1] + } + $unitName = $highestUnitComponent[0] + $unitAbbr = $highestUnitComponent[2] + + # Compute the full timespan in the chosen unit. + switch ($unitName) { + 'Years' { $fractionalValue = $originalTicks / $ticksInYear } + 'Months' { $fractionalValue = $originalTicks / $ticksInMonth } + 'Weeks' { $fractionalValue = $originalTicks / $ticksInWeek } + 'Days' { $fractionalValue = $originalTicks / $ticksInDay } + 'Hours' { $fractionalValue = $originalTicks / $ticksInHour } + 'Minutes' { $fractionalValue = $originalTicks / $ticksInMinute } + 'Seconds' { $fractionalValue = $originalTicks / $ticksInSecond } + 'Milliseconds' { $fractionalValue = $originalTicks / $ticksInMillisecond } + 'Microseconds' { $fractionalValue = $originalTicks / 10 } + 'Nanoseconds' { $fractionalValue = $originalTicks * 100 } + } + + # Round to the nearest integer. + $roundedValue = [math]::Round($fractionalValue, 0, [System.MidpointRounding]::AwayFromZero) + $formatted = "$roundedValue$unitAbbr" + if ($isNegative) { + $formatted = "-$formatted" + } + return $formatted +} + +function Get-GroupedTestMarkdown { + param( + [Parameter(Mandatory)] + [array]$Tests, + [int]$Depth + ) + $markdown = '' + # Group tests by the element at position $Depth (or "Ungrouped" if not present) + $groups = $Tests | Group-Object { if ($_.Path.Count -gt $Depth) { $_.Path[$Depth] } else { 'Ungrouped' } } | Sort-Object Name + foreach ($group in $groups) { + $groupName = $group.Name + $groupTests = $group.Group + $groupIndent = $Indent * ($Depth + 2) + # Calculate aggregate status: if any test failed, mark the group as failed + $groupStatusIcon = if ($groupTests | Where-Object { $_.Result -eq 'Failed' }) { '❌' } else { '✅' } + # Calculate aggregate duration: sum all test durations + $groupDuration = [System.TimeSpan]::Zero + $groupTests.Duration | ForEach-Object { $groupDuration += $_ } + $formattedGroupDuration = $groupDuration | Format-TimeSpan + + # If any test has further parts, create a nested details block... + if ($groupTests | Where-Object { $_.Path.Count -gt ($Depth + 1) }) { + $markdown += @" +
$groupIndent$groupStatusIcon - $groupName ($formattedGroupDuration) +

+$(Get-GroupedTestMarkdown -Tests $groupTests -Depth ($Depth + 1)) +

+
+ +"@ + } else { + # Otherwise, list each test at this level + foreach ($test in $groupTests) { + $testName = $test.Path[$Depth] + $testStatusIcon = $test.Result -eq 'Passed' ? '✅' : '❌' + $formattedDuration = $test.Duration | Format-TimeSpan + $markdown += @" +
$groupIndent$testStatusIcon - $testName ($formattedDuration) +

+ +"@ + + if ($test.Result -eq 'Failed' -and $test.ErrorRecord.Exception.Message) { + $markdown += @" + +`````` +$($test.ErrorRecord.Exception.Message) +`````` + +"@ + } + $markdown += @' +

+
+ +'@ + } + } + } + return $markdown +} + +$nbsp = [char]0x00A0 +$indent = "$nbsp" * 4 diff --git a/scripts/Pester.Configuration.ps1 b/scripts/Pester.Configuration.ps1 new file mode 100644 index 00000000..facd6bfc --- /dev/null +++ b/scripts/Pester.Configuration.ps1 @@ -0,0 +1,70 @@ +@{ + Run = @{ + Path = $Path + ExcludePath = @() + ScriptBlock = @() + Container = @() + TestExtension = @( + '.Tests.ps1' + ) + Exit = $false + Throw = $false + PassThru = $true + SkipRun = $false + SkipRemainingOnFailure = 'None' + } + Filter = @{ + Tag = @() + ExcludeTag = @() + Line = @() + ExcludeLine = @() + FullName = @() + } + CodeCoverage = @{ + Enabled = $true + OutputFormat = 'JaCoCo' + # OutputPath = 'test_reports/-CodeCoverage-Report.xml' + OutputEncoding = 'UTF8' + Path = @() + ExcludeTests = $true + RecursePaths = $true + CoveragePercentTarget = 75.0 + UseBreakpoints = $true + SingleHitBreakpoints = $true + } + TestResult = @{ + Enabled = $true + OutputFormat = 'NUnitXml' + # OutputPath = 'test_reports/-TestResult-Report.xml' + OutputEncoding = 'UTF8' + TestSuiteName = 'Unit tests' + } + Should = @{ + ErrorAction = 'Stop' + } + Debug = @{ + ShowFullErrors = $false + WriteDebugMessages = $false + WriteDebugMessagesFrom = @( + 'Discovery', + 'Skip', + 'Mock', + 'CodeCoverage' + ) + ShowNavigationMarkers = $false + ReturnRawResultObject = $false + } + Output = @{ + CIFormat = 'Auto' + StackTraceVerbosity = 'Filtered' + Verbosity = 'Detailed' + CILogLevel = 'Error' + RenderMode = 'Auto' + } + TestDrive = @{ + Enabled = $true + } + TestRegistry = @{ + Enabled = $true + } +} diff --git a/scripts/main.ps1 b/scripts/main.ps1 index b054fccf..42f85c11 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -1,7 +1,353 @@ -[CmdletBinding()] -param( - [Parameter()] - [string] $Subject = $env:GITHUB_ACTION_INPUT_subject -) - -Write-Host "Hello, $subject!" + +[CmdletBinding()] +param() + +LogGroup 'Setup prerequisites' { + 'Pester', 'PSScriptAnalyzer' | ForEach-Object { + Install-PSResource -Name $_ -Verbose:$false -WarningAction SilentlyContinue -TrustRepository -Repository PSGallery + Import-Module -Name $_ -Verbose:$false + } + Import-Module "$PSScriptRoot/Helpers.psm1" +} + +LogGroup 'Get test kit versions' { + $PSSAModule = Get-PSResource -Name PSScriptAnalyzer -Verbose:$false | Sort-Object Version -Descending | Select-Object -First 1 + $pesterModule = Get-PSResource -Name Pester -Verbose:$false | Sort-Object Version -Descending | Select-Object -First 1 + + [PSCustomObject]@{ + PowerShell = $PSVersionTable.PSVersion.ToString() + Pester = $pesterModule.Version + PSScriptAnalyzer = $PSSAModule.Version + } | Format-List +} + +LogGroup 'Load inputs' { + $inputs = @{ + Path = $env:GITHUB_ACTION_INPUT_Path + + Run_Path = $env:GITHUB_ACTION_INPUT_Run_Path + Run_ExcludePath = $env:GITHUB_ACTION_INPUT_Run_ExcludePath + Run_ScriptBlock = $env:GITHUB_ACTION_INPUT_Run_ScriptBlock + Run_Container = $env:GITHUB_ACTION_INPUT_Run_Container + Run_TestExtension = $env:GITHUB_ACTION_INPUT_Run_TestExtension + Run_Exit = $env:GITHUB_ACTION_INPUT_Run_Exit + Run_Throw = $env:GITHUB_ACTION_INPUT_Run_Throw + Run_PassThru = $env:GITHUB_ACTION_INPUT_Run_PassThru + Run_SkipRun = $env:GITHUB_ACTION_INPUT_Run_SkipRun + Run_SkipRemainingOnFailure = $env:GITHUB_ACTION_INPUT_Run_SkipRemainingOnFailure + + Filter_Tag = $env:GITHUB_ACTION_INPUT_Filter_Tag + Filter_ExcludeTag = $env:GITHUB_ACTION_INPUT_Filter_ExcludeTag + Filter_Line = $env:GITHUB_ACTION_INPUT_Filter_Line + Filter_ExcludeLine = $env:GITHUB_ACTION_INPUT_Filter_ExcludeLine + Filter_FullName = $env:GITHUB_ACTION_INPUT_Filter_FullName + + CodeCoverage_Enabled = $env:GITHUB_ACTION_INPUT_CodeCoverage_Enabled + CodeCoverage_OutputFormat = $env:GITHUB_ACTION_INPUT_CodeCoverage_OutputFormat + CodeCoverage_OutputPath = $env:GITHUB_ACTION_INPUT_CodeCoverage_OutputPath + CodeCoverage_OutputEncoding = $env:GITHUB_ACTION_INPUT_CodeCoverage_OutputEncoding + CodeCoverage_Path = $env:GITHUB_ACTION_INPUT_CodeCoverage_Path + CodeCoverage_ExcludeTests = $env:GITHUB_ACTION_INPUT_CodeCoverage_ExcludeTests + CodeCoverage_RecursePaths = $env:GITHUB_ACTION_INPUT_CodeCoverage_RecursePaths + CodeCoverage_CoveragePercentTarget = $env:GITHUB_ACTION_INPUT_CodeCoverage_CoveragePercentTarget + CodeCoverage_UseBreakpoints = $env:GITHUB_ACTION_INPUT_CodeCoverage_UseBreakpoints + CodeCoverage_SingleHitBreakpoints = $env:GITHUB_ACTION_INPUT_CodeCoverage_SingleHitBreakpoints + + TestResult_Enabled = $env:GITHUB_ACTION_INPUT_TestResult_Enabled + TestResult_OutputFormat = $env:GITHUB_ACTION_INPUT_TestResult_OutputFormat + TestResult_OutputPath = $env:GITHUB_ACTION_INPUT_TestResult_OutputPath + TestResult_OutputEncoding = $env:GITHUB_ACTION_INPUT_TestResult_OutputEncoding + TestResult_TestSuiteName = $env:GITHUB_ACTION_INPUT_TestResult_TestSuiteName + + Should_ErrorAction = $env:GITHUB_ACTION_INPUT_Should_ErrorAction + + Debug_ShowFullErrors = $env:GITHUB_ACTION_INPUT_Debug_ShowFullErrors + Debug_WriteDebugMessages = $env:GITHUB_ACTION_INPUT_Debug_WriteDebugMessages + Debug_WriteDebugMessagesFrom = $env:GITHUB_ACTION_INPUT_Debug_WriteDebugMessagesFrom + Debug_ShowNavigationMarkers = $env:GITHUB_ACTION_INPUT_Debug_ShowNavigationMarkers + Debug_ReturnRawResultObject = $env:GITHUB_ACTION_INPUT_Debug_ReturnRawResultObject + + Output_Verbosity = $env:GITHUB_ACTION_INPUT_Output_Verbosity + Output_StackTraceVerbosity = $env:GITHUB_ACTION_INPUT_Output_StackTraceVerbosity + Output_CIFormat = $env:GITHUB_ACTION_INPUT_Output_CIFormat + Output_CILogLevel = $env:GITHUB_ACTION_INPUT_Output_CILogLevel + Output_RenderMode = $env:GITHUB_ACTION_INPUT_Output_RenderMode + + TestDrive_Enabled = $env:GITHUB_ACTION_INPUT_TestDrive_Enabled + TestRegistry_Enabled = $env:GITHUB_ACTION_INPUT_TestRegistry_Enabled + } + + [pscustomobject]($inputs.GetEnumerator() | Where-Object { -not [string]::IsNullOrEmpty($_.Value) }) | Format-List +} + +$customConfig = @{} +$customInputs = @{} + +LogGroup 'Load configuration - Defaults' { + $defaultConfigPath = (Join-Path $PSScriptRoot -ChildPath 'Pester.Configuration.ps1') + if (Test-Path -Path $defaultConfigPath) { + $tmpDefault = . $defaultConfigPath + } + $defaultConfig = @{ + Run = $tmpDefault.Run ?? @{} + Filter = $tmpDefault.Filter ?? @{} + CodeCoverage = $tmpDefault.CodeCoverage ?? @{} + TestResult = $tmpDefault.TestResult ?? @{} + Should = $tmpDefault.Should ?? @{} + Debug = $tmpDefault.Debug ?? @{} + Output = $tmpDefault.Output ?? @{} + TestDrive = $tmpDefault.TestDrive ?? @{} + TestRegistry = $tmpDefault.TestRegistry ?? @{} + } + Write-Output ($defaultConfig | ConvertTo-Json -Depth 5 -WarningAction SilentlyContinue) +} + +LogGroup 'Load configuration - Custom settings file' { + $tmpCustom = Get-PesterConfiguration -Path $inputs.Path + $tmpCustomConfiguration = @{ + Run = $tmpCustom.Run ?? @{} + Filter = $tmpCustom.Filter ?? @{} + CodeCoverage = $tmpCustom.CodeCoverage ?? @{} + TestResult = $tmpCustom.TestResult ?? @{} + Should = $tmpCustom.Should ?? @{} + Debug = $tmpCustom.Debug ?? @{} + Output = $tmpCustom.Output ?? @{} + TestDrive = $tmpCustom.TestDrive ?? @{} + TestRegistry = $tmpCustom.TestRegistry ?? @{} + } + + $customConfig = $tmpCustomConfiguration | Clear-PesterConfigurationEmptyValues + Write-Output ($customConfig | ConvertTo-Json -Depth 5 -WarningAction SilentlyContinue) +} + +LogGroup 'Load configuration - Action overrides' { + $customConfigInputMap = @{ + Run = @{ + Path = $inputs.Run_Path + ExcludePath = $inputs.Run_ExcludePath + ScriptBlock = $inputs.Run_ScriptBlock + Container = $inputs.Run_Container + TestExtension = $inputs.Run_TestExtension + Exit = $inputs.Run_Exit + Throw = $inputs.Run_Throw + PassThru = $inputs.Run_PassThru + SkipRun = $inputs.Run_SkipRun + SkipRemainingOnFailure = $inputs.Run_SkipRemainingOnFailure + } + Filter = @{ + Tag = $inputs.Filter_Tag + ExcludeTag = $inputs.Filter_ExcludeTag + Line = $inputs.Filter_Line + ExcludeLine = $inputs.Filter_ExcludeLine + FullName = $inputs.Filter_FullName + } + CodeCoverage = @{ + Enabled = $inputs.CodeCoverage_Enabled + OutputFormat = $inputs.CodeCoverage_OutputFormat + OutputPath = $inputs.CodeCoverage_OutputPath + OutputEncoding = $inputs.CodeCoverage_OutputEncoding + Path = $inputs.CodeCoverage_Path + ExcludeTests = $inputs.CodeCoverage_ExcludeTests + RecursePaths = $inputs.CodeCoverage_RecursePaths + CoveragePercentTarget = $inputs.CodeCoverage_CoveragePercentTarget + UseBreakpoints = $inputs.CodeCoverage_UseBreakpoints + SingleHitBreakpoints = $inputs.CodeCoverage_SingleHitBreakpoints + } + TestResult = @{ + Enabled = $inputs.TestResult_Enabled + OutputFormat = $inputs.TestResult_OutputFormat + OutputPath = $inputs.TestResult_OutputPath + OutputEncoding = $inputs.TestResult_OutputEncoding + TestSuiteName = $inputs.TestResult_TestSuiteName + } + Should = @{ + ErrorAction = $inputs.Should_ErrorAction + } + Debug = @{ + ShowFullErrors = $inputs.Debug_ShowFullErrors + WriteDebugMessages = $inputs.Debug_WriteDebugMessages + WriteDebugMessagesFrom = $inputs.Debug_WriteDebugMessagesFrom + ShowNavigationMarkers = $inputs.Debug_ShowNavigationMarkers + ReturnRawResultObject = $inputs.Debug_ReturnRawResultObject + } + Output = @{ + CIFormat = $inputs.Output_CIFormat + StackTraceVerbosity = $inputs.Output_StackTraceVerbosity + Verbosity = $inputs.Output_Verbosity + CILogLevel = $inputs.Output_CILogLevel + RenderMode = $inputs.Output_RenderMode + } + TestDrive = @{ + Enabled = $inputs.TestDrive_Enabled + } + TestRegistry = @{ + Enabled = $inputs.TestRegistry_Enabled + } + } + + $customInputs = $customConfigInputMap | Clear-PesterConfigurationEmptyValues + Write-Output ($customInputs | ConvertTo-Json -Depth 5 -WarningAction SilentlyContinue) +} + +LogGroup 'Load configuration - Merge' { + $run = Merge-Hashtable -Main $defaultConfig.Run -Overrides $customConfig.Run, $customInputs.Run + $filter = Merge-Hashtable -Main $defaultConfig.Filter -Overrides $customConfig.Filter, $customInputs.Filter + $codeCoverage = Merge-Hashtable -Main $defaultConfig.CodeCoverage -Overrides $customConfig.CodeCoverage, $customInputs.CodeCoverage + $testResult = Merge-Hashtable -Main $defaultConfig.TestResult -Overrides $customConfig.TestResult, $customInputs.TestResult + $should = Merge-Hashtable -Main $defaultConfig.Should -Overrides $customConfig.Should, $customInputs.Should + $debug = Merge-Hashtable -Main $defaultConfig.Debug -Overrides $customConfig.Debug, $customInputs.Debug + $output = Merge-Hashtable -Main $defaultConfig.Output -Overrides $customConfig.Output, $customInputs.Output + $testDrive = Merge-Hashtable -Main $defaultConfig.TestDrive -Overrides $customConfig.TestDrive, $customInputs.TestDrive + $testRegistry = Merge-Hashtable -Main $defaultConfig.TestRegistry -Overrides $customConfig.TestRegistry, $customInputs.TestRegistry + + $configuration = @{ + Run = $run + Filter = $filter + CodeCoverage = $codeCoverage + TestResult = $testResult + Should = $should + Debug = $debug + Output = $output + TestDrive = $testDrive + TestRegistry = $testRegistry + } + + if (-not $configuration.Run.Path) { + $configuration.Run.Path = $inputs.Path + } +} + +LogGroup 'Load configuration - Add containers' { + Write-Output "Containers from configuration: [$($configuration.Run.Container.Count)]" + Write-Output ($configuration.Run.Container | ConvertTo-Json -Depth 2 -WarningAction SilentlyContinue) + + # Load configuration - Add containers + if ($configuration.Run.Container.Count -eq 0) { + # If no containers are specified, search for "*.Container.*" files in each Run.Path directory + foreach ($testDir in $configuration.Run.Path) { + if (Test-Path -LiteralPath $testDir -PathType Container) { + $configuration.Run.Container += Get-PesterContainer -Path $testDir + } + } + } + + # If any containers are defined as hashtables, convert them to PesterContainer objects + for ($i = 0; $i -lt $configuration.Run.Container.Count; $i++) { + $cntnr = $configuration.Run.Container[$i] + if ($cntnr -is [hashtable]) { + $configuration.Run.Container[$i] = New-PesterContainer @cntnr + } + } + + Write-Output "Added containers: [$($configuration.Run.Container.Count)]" + Write-Output ($configuration.Run.Container | ConvertTo-Json -Depth 2 -WarningAction SilentlyContinue) +} + +LogGroup 'Load configuration - Result' { + $artifactName = $configuration.TestResult.TestSuiteName + $configuration.TestResult.OutputPath = "test_reports/$artifactName-TestResult-Report.xml" + $configuration.CodeCoverage.OutputPath = "test_reports/$artifactName-CodeCoverage-Report.xml" + + Write-Output ($configuration | ConvertTo-Json -Depth 5 -WarningAction SilentlyContinue) +} + +$testResults = Invoke-Pester -Configuration $configuration + +LogGroup 'Test results' { + $testResults | Format-List + + $failedTests = [int]$testResults.FailedCount + + if (($failedTests -gt 0) -or ($testResults.Result -ne 'Passed')) { + Write-GitHubError "❌ Some [$failedTests] tests failed." + } + if ($failedTests -eq 0) { + Write-GitHubNotice '✅ All tests passed.' + } +} + +LogGroup 'Test results summary' { + + $nbsp = [char]0x00A0 + $indent = "$nbsp" * 4 + + $totalTests = $testResults.TotalCount + $passedTests = $testResults.PassedCount + $failedTests = $testResults.FailedCount + $skippedTests = $testResults.SkippedCount + $inconclusiveTests = $testResults.InconclusiveCount + $notRunTests = $testResults.NotRunCount + + $coverageString = 'N/A' + if ($configuration.CodeCoverage.Enabled) { + $coverage = [System.Math]::Round(($testResults.CodeCoverage.CoveragePercent), 2) + $coverageString = "$coverage%" + } + + $testSuitName = $($configuration.TestResult.TestSuiteName) + $testSuitStatusIcon = if ($failedTests -gt 0) { '❌' } else { '✅' } + $formattedTestDuration = $testResults.Duration | Format-TimeSpan + $summaryMarkdown = @" + +
$testSuitStatusIcon - $testSuitName ($formattedTestDuration) +

+ +| Total | Passed | Failed | Skipped | Inconclusive | NotRun | Coverage | +| ----- | ------ | ------ | ------- | ------------ | ------ | -------- | +| $($totalTests) | $($passedTests) | $($failedTests) | $($skippedTests) | $($inconclusiveTests) | $($notRunTests) | $coverageString | + +"@ + + Write-Verbose "Processing containers [$($testResults.Containers.Count)]" -Verbose + # For each container, group tests by their test path parts + foreach ($container in $testResults.Containers) { + $containerPath = $container.Item.FullName + Write-Verbose "Processing container [$containerPath]" -Verbose + $containerName = (Split-Path $container.Name -Leaf) -replace '.Tests.ps1' + Write-Verbose "Container name: [$containerName]" -Verbose + $containerStatusIcon = $container.Result -eq 'Passed' ? '✅' : '❌' + $formattedContainerDuration = $container.Duration | Format-TimeSpan + $summaryMarkdown += @" +

$Indent$containerStatusIcon - $containerName ($formattedContainerDuration) +

+ +"@ + $containerTests = $testResults.Tests | Where-Object { $_.Block.BlockContainer.Item.FullName -eq $containerPath } | Sort-Object -Property Path + Write-Verbose "Processing tests [$($containerTests.Count)]" -Verbose + + # Build the nested details markdown grouping tests by their test path parts + $groupedMarkdown = Get-GroupedTestMarkdown -Tests $containerTests -Depth 0 + $summaryMarkdown += $groupedMarkdown + + $summaryMarkdown += @' + +

+
+ +'@ + } + + $summaryMarkdown += @' + +

+
+ +'@ + Set-GitHubStepSummary -Summary $summaryMarkdown +} + +# For each property of testresults, output the value as a JSON object +foreach ($property in $testResults.PSObject.Properties) { + Write-Verbose "Setting output for [$($property.Name)]" + $name = $property.Name + $value = -not [string]::IsNullOrEmpty($property.Value) ? ($property.Value | ConvertTo-Json -Depth 2 -WarningAction SilentlyContinue) : '' + Set-GitHubOutput -Name $name -Value $value +} + +Set-GitHubOutput -Name 'TestResultEnabled' -Value $testResults.Configuration.TestResult.Enabled.Value +Set-GitHubOutput -Name 'TestResultOutputPath' -Value $testResults.Configuration.TestResult.OutputPath.Value +Set-GitHubOutput -Name 'TestSuiteName' -Value $testResults.Configuration.TestResult.TestSuiteName.Value +Set-GitHubOutput -Name 'CodeCoverageEnabled' -Value $testResults.Configuration.CodeCoverage.Enabled.Value +Set-GitHubOutput -Name 'CodeCoverageOutputPath' -Value $testResults.Configuration.CodeCoverage.OutputPath.Value + +exit $failedTests diff --git a/scripts/main.sh b/scripts/main.sh deleted file mode 100644 index 8589a85d..00000000 --- a/scripts/main.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -# Run 'git add --chmod=+x scripts\main.sh' to make the script executable - -echo "Hello, $SUBJECT!" diff --git a/tests/1-Simple/Emoji.Tests.ps1 b/tests/1-Simple/Emoji.Tests.ps1 new file mode 100644 index 00000000..2db4ac47 --- /dev/null +++ b/tests/1-Simple/Emoji.Tests.ps1 @@ -0,0 +1,67 @@ +BeforeAll { + $emojis = @( + @{ Name = 'apple'; Symbol = '🍎'; Kind = 'Fruit' } + @{ Name = 'beaming face with smiling eyes'; Symbol = '😁'; Kind = 'Face' } + @{ Name = 'cactus'; Symbol = '🌵'; Kind = 'Plant' } + @{ Name = 'giraffe'; Symbol = '🦒'; Kind = 'Animal' } + @{ Name = 'pencil'; Symbol = '✏️'; Kind = 'Item' } + @{ Name = 'penguin'; Symbol = '🐧'; Kind = 'Animal' } + @{ Name = 'pensive'; Symbol = '😔'; Kind = 'Face' } + @{ Name = 'slightly smiling face'; Symbol = '🙂'; Kind = 'Face' } + @{ Name = 'smiling face with smiling eyes'; Symbol = '😊'; Kind = 'Face' } + ) | ForEach-Object { [PSCustomObject]$_ } + + function Get-Emoji ([string]$Name = '*') { + $emojis | Where-Object Name -Like $Name | ForEach-Object Symbol + } +} + +Describe 'Get-Emoji' { + Context 'Lookup by whole name' { + It 'Returns 🌵 (cactus)' { + Get-Emoji -Name cactus | Should -Be '🌵' + } + + It 'Returns 🦒 (giraffe)' { + Get-Emoji -Name giraffe | Should -Be '🦒' + } + } + + Context 'Lookup by wildcard' { + Context 'by prefix' { + BeforeAll { + $emojis = Get-Emoji -Name pen* + } + + It 'Returns ✏️ (pencil)' { + $emojis | Should -Contain '✏️' + } + + It 'Returns 🐧 (penguin)' { + $emojis | Should -Contain '🐧' + } + + It 'Returns 😔 (pensive)' { + $emojis | Should -Contain '😔' + } + } + + Context 'by contains' { + BeforeAll { + $emojis = Get-Emoji -Name *smiling* + } + + It 'Returns 🙂 (slightly smiling face)' { + $emojis | Should -Contain '🙂' + } + + It 'Returns 😁 (beaming face with smiling eyes)' { + $emojis | Should -Contain '😁' + } + + It 'Returns 😊 (smiling face with smiling eyes)' { + $emojis | Should -Contain '😊' + } + } + } +} diff --git a/tests/2-Standard/Emoji.Configuration.ps1 b/tests/2-Standard/Emoji.Configuration.ps1 new file mode 100644 index 00000000..d91b1e1b --- /dev/null +++ b/tests/2-Standard/Emoji.Configuration.ps1 @@ -0,0 +1,15 @@ +@{ + Run = @{ + Path = $PSScriptRoot + PassThru = $true + } + TestResult = @{ + Enabled = $true + TestSuiteName = 'Standard' + } + Output = @{ + CIFormat = 'Auto' + StackTraceVerbosity = 'Filtered' + Verbosity = 'Detailed' + } +} diff --git a/tests/2-Standard/Emoji.Container.ps1 b/tests/2-Standard/Emoji.Container.ps1 new file mode 100644 index 00000000..dacb12f9 --- /dev/null +++ b/tests/2-Standard/Emoji.Container.ps1 @@ -0,0 +1,8 @@ +@{ + Path = Get-ChildItem -Path $PSScriptRoot -Filter *.Tests.ps1 | Select-Object -ExpandProperty FullName + Data = @{ + Path = Join-Path $PSScriptRoot -ChildPath 'Emoji.psm1' + Debug = $false + Verbose = $false + } +} diff --git a/tests/2-Standard/Emoji.Tests.ps1 b/tests/2-Standard/Emoji.Tests.ps1 new file mode 100644 index 00000000..78f88ae3 --- /dev/null +++ b/tests/2-Standard/Emoji.Tests.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $Path +) + +Describe 'Emoji' { + It 'Module is importable' { + { Import-Module -Name $Path } | Should -Not -Throw + } +} + +Describe 'Get-Emoji' { + Context 'Lookup by whole name' { + It 'Returns 🌵 (cactus)' { + Get-Emoji -Name cactus | Should -Be '🌵' + } + + It 'Returns 🦒 (giraffe)' { + Get-Emoji -Name giraffe | Should -Be '🦒' + } + } + + Context 'Lookup by wildcard' { + Context 'by prefix' { + BeforeAll { + $emojis = Get-Emoji -Name pen* + } + + It 'Returns ✏️ (pencil)' { + $emojis | Should -Contain '✏️' + } + + It 'Returns 🐧 (penguin)' { + $emojis | Should -Contain '🐧' + } + + It 'Returns 😔 (pensive)' { + $emojis | Should -Contain '😔' + } + } + + Context 'by contains' { + BeforeAll { + $emojis = Get-Emoji -Name *smiling* + } + + It 'Returns 🙂 (slightly smiling face)' { + $emojis | Should -Contain '🙂' + } + + It 'Returns 😁 (beaming face with smiling eyes)' { + $emojis | Should -Contain '😁' + } + + It 'Returns 😊 (smiling face with smiling eyes)' { + $emojis | Should -Contain '😊' + } + } + } +} diff --git a/tests/2-Standard/Emoji.psm1 b/tests/2-Standard/Emoji.psm1 new file mode 100644 index 00000000..d27e0317 --- /dev/null +++ b/tests/2-Standard/Emoji.psm1 @@ -0,0 +1,15 @@ +$script:emojis = @( + @{ Name = 'apple'; Symbol = '🍎'; Kind = 'Fruit' } + @{ Name = 'beaming face with smiling eyes'; Symbol = '😁'; Kind = 'Face' } + @{ Name = 'cactus'; Symbol = '🌵'; Kind = 'Plant' } + @{ Name = 'giraffe'; Symbol = '🦒'; Kind = 'Animal' } + @{ Name = 'pencil'; Symbol = '✏️'; Kind = 'Item' } + @{ Name = 'penguin'; Symbol = '🐧'; Kind = 'Animal' } + @{ Name = 'pensive'; Symbol = '😔'; Kind = 'Face' } + @{ Name = 'slightly smiling face'; Symbol = '🙂'; Kind = 'Face' } + @{ Name = 'smiling face with smiling eyes'; Symbol = '😊'; Kind = 'Face' } +) | ForEach-Object { [PSCustomObject]$_ } + +function Get-Emoji ([string]$Name = '*') { + $script:emojis | Where-Object Name -Like $Name | ForEach-Object Symbol +} diff --git a/tests/3-Advanced/Animals/Animals.Container.ps1 b/tests/3-Advanced/Animals/Animals.Container.ps1 new file mode 100644 index 00000000..dce74d95 --- /dev/null +++ b/tests/3-Advanced/Animals/Animals.Container.ps1 @@ -0,0 +1,8 @@ +@{ + Path = Get-ChildItem -Path $PSScriptRoot -Filter *.Tests.ps1 | Select-Object -ExpandProperty FullName + Data = @{ + Path = Get-ChildItem -Path $PSScriptRoot -Filter *.Data.ps1 | Select-Object -ExpandProperty FullName + Debug = $false + Verbose = $false + } +} diff --git a/tests/3-Advanced/Animals/Animals.Data.ps1 b/tests/3-Advanced/Animals/Animals.Data.ps1 new file mode 100644 index 00000000..0e070679 --- /dev/null +++ b/tests/3-Advanced/Animals/Animals.Data.ps1 @@ -0,0 +1,8 @@ +# Animals.Settings.ps1 +$AnimalsSettings = @{ + # Expected number of animal types for testing + AnimalCount = 5 + + # Expected list of animal names for testing + AnimalNames = @('Lion', 'Tiger', 'Bear', 'Elephant', 'Giraffe') +} diff --git a/tests/3-Advanced/Animals/Animals.Tests.ps1 b/tests/3-Advanced/Animals/Animals.Tests.ps1 new file mode 100644 index 00000000..07484f69 --- /dev/null +++ b/tests/3-Advanced/Animals/Animals.Tests.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $Path +) + +BeforeAll { + . $Path +} + +Describe 'Animals Module Tests' { + Context 'Animal Count' { + It 'Should return the expected number of animals' { + $expectedCount = $AnimalsSettings.AnimalCount + $actualCount = $AnimalsSettings.AnimalNames.Count + $actualCount | Should -Be $expectedCount + } + } + Context 'Animal Names' { + It 'Should return the expected list of animal names' { + $expectedNames = $AnimalsSettings.AnimalNames + $actualNames = $AnimalsSettings.AnimalNames + $actualNames | Should -BeExactly $expectedNames + } + } +} diff --git a/tests/3-Advanced/Cars/Cars.Container.ps1 b/tests/3-Advanced/Cars/Cars.Container.ps1 new file mode 100644 index 00000000..dce74d95 --- /dev/null +++ b/tests/3-Advanced/Cars/Cars.Container.ps1 @@ -0,0 +1,8 @@ +@{ + Path = Get-ChildItem -Path $PSScriptRoot -Filter *.Tests.ps1 | Select-Object -ExpandProperty FullName + Data = @{ + Path = Get-ChildItem -Path $PSScriptRoot -Filter *.Data.ps1 | Select-Object -ExpandProperty FullName + Debug = $false + Verbose = $false + } +} diff --git a/tests/3-Advanced/Cars/Cars.Data.ps1 b/tests/3-Advanced/Cars/Cars.Data.ps1 new file mode 100644 index 00000000..d6394eee --- /dev/null +++ b/tests/3-Advanced/Cars/Cars.Data.ps1 @@ -0,0 +1,8 @@ +# Cars.Settings.ps1 +$CarsSettings = @{ + # Expected number of car models for testing + CarCount = 3 + + # Expected list of car models for testing + CarModels = @('Sedan', 'SUV', 'Coupe') +} diff --git a/tests/3-Advanced/Cars/Cars.Tests.ps1 b/tests/3-Advanced/Cars/Cars.Tests.ps1 new file mode 100644 index 00000000..3dc253d3 --- /dev/null +++ b/tests/3-Advanced/Cars/Cars.Tests.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $Path +) + +BeforeAll { + . $Path +} + +Describe 'Cars Module Tests' { + + Context 'Car Count' { + It 'Should return the expected number of car models' { + $expectedCount = $CarsSettings.CarCount + $actualCount = $CarsSettings.CarModels.Count + $actualCount | Should -Be $expectedCount + } + } + + Context 'Car Models' { + It 'Should return the expected list of car models' { + $expectedModels = $CarsSettings.CarModels + $actualModels = $CarsSettings.CarModels + $actualModels | Should -BeExactly $expectedModels + } + } +} diff --git a/tests/3-Advanced/Emoji/Emoji.Container.ps1 b/tests/3-Advanced/Emoji/Emoji.Container.ps1 new file mode 100644 index 00000000..dacb12f9 --- /dev/null +++ b/tests/3-Advanced/Emoji/Emoji.Container.ps1 @@ -0,0 +1,8 @@ +@{ + Path = Get-ChildItem -Path $PSScriptRoot -Filter *.Tests.ps1 | Select-Object -ExpandProperty FullName + Data = @{ + Path = Join-Path $PSScriptRoot -ChildPath 'Emoji.psm1' + Debug = $false + Verbose = $false + } +} diff --git a/tests/3-Advanced/Emoji/Emoji.Tests.ps1 b/tests/3-Advanced/Emoji/Emoji.Tests.ps1 new file mode 100644 index 00000000..78f88ae3 --- /dev/null +++ b/tests/3-Advanced/Emoji/Emoji.Tests.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $Path +) + +Describe 'Emoji' { + It 'Module is importable' { + { Import-Module -Name $Path } | Should -Not -Throw + } +} + +Describe 'Get-Emoji' { + Context 'Lookup by whole name' { + It 'Returns 🌵 (cactus)' { + Get-Emoji -Name cactus | Should -Be '🌵' + } + + It 'Returns 🦒 (giraffe)' { + Get-Emoji -Name giraffe | Should -Be '🦒' + } + } + + Context 'Lookup by wildcard' { + Context 'by prefix' { + BeforeAll { + $emojis = Get-Emoji -Name pen* + } + + It 'Returns ✏️ (pencil)' { + $emojis | Should -Contain '✏️' + } + + It 'Returns 🐧 (penguin)' { + $emojis | Should -Contain '🐧' + } + + It 'Returns 😔 (pensive)' { + $emojis | Should -Contain '😔' + } + } + + Context 'by contains' { + BeforeAll { + $emojis = Get-Emoji -Name *smiling* + } + + It 'Returns 🙂 (slightly smiling face)' { + $emojis | Should -Contain '🙂' + } + + It 'Returns 😁 (beaming face with smiling eyes)' { + $emojis | Should -Contain '😁' + } + + It 'Returns 😊 (smiling face with smiling eyes)' { + $emojis | Should -Contain '😊' + } + } + } +} diff --git a/tests/3-Advanced/Emoji/Emoji.psm1 b/tests/3-Advanced/Emoji/Emoji.psm1 new file mode 100644 index 00000000..d27e0317 --- /dev/null +++ b/tests/3-Advanced/Emoji/Emoji.psm1 @@ -0,0 +1,15 @@ +$script:emojis = @( + @{ Name = 'apple'; Symbol = '🍎'; Kind = 'Fruit' } + @{ Name = 'beaming face with smiling eyes'; Symbol = '😁'; Kind = 'Face' } + @{ Name = 'cactus'; Symbol = '🌵'; Kind = 'Plant' } + @{ Name = 'giraffe'; Symbol = '🦒'; Kind = 'Animal' } + @{ Name = 'pencil'; Symbol = '✏️'; Kind = 'Item' } + @{ Name = 'penguin'; Symbol = '🐧'; Kind = 'Animal' } + @{ Name = 'pensive'; Symbol = '😔'; Kind = 'Face' } + @{ Name = 'slightly smiling face'; Symbol = '🙂'; Kind = 'Face' } + @{ Name = 'smiling face with smiling eyes'; Symbol = '😊'; Kind = 'Face' } +) | ForEach-Object { [PSCustomObject]$_ } + +function Get-Emoji ([string]$Name = '*') { + $script:emojis | Where-Object Name -Like $Name | ForEach-Object Symbol +} diff --git a/tests/3-Advanced/Pester.Configuration.ps1 b/tests/3-Advanced/Pester.Configuration.ps1 new file mode 100644 index 00000000..791150a6 --- /dev/null +++ b/tests/3-Advanced/Pester.Configuration.ps1 @@ -0,0 +1,17 @@ +@{ + Run = @{ + Path = $PSScriptRoot + PassThru = $true + Container = Get-ChildItem -Path $PSScriptRoot -Filter *.Container.* -Recurse | + ForEach-Object { . $_ } | ForEach-Object { New-PesterContainer @_ } + } + TestResult = @{ + Enabled = $true + TestSuiteName = 'Advanced' + } + Output = @{ + CIFormat = 'Auto' + StackTraceVerbosity = 'Filtered' + Verbosity = 'Detailed' + } +} diff --git a/tests/3-Advanced/Planets/Planets.Container.ps1 b/tests/3-Advanced/Planets/Planets.Container.ps1 new file mode 100644 index 00000000..dce74d95 --- /dev/null +++ b/tests/3-Advanced/Planets/Planets.Container.ps1 @@ -0,0 +1,8 @@ +@{ + Path = Get-ChildItem -Path $PSScriptRoot -Filter *.Tests.ps1 | Select-Object -ExpandProperty FullName + Data = @{ + Path = Get-ChildItem -Path $PSScriptRoot -Filter *.Data.ps1 | Select-Object -ExpandProperty FullName + Debug = $false + Verbose = $false + } +} diff --git a/tests/3-Advanced/Planets/Planets.Data.ps1 b/tests/3-Advanced/Planets/Planets.Data.ps1 new file mode 100644 index 00000000..31a192d8 --- /dev/null +++ b/tests/3-Advanced/Planets/Planets.Data.ps1 @@ -0,0 +1,8 @@ +# Planets.Settings.ps1 +$PlanetsSettings = @{ + # Expected number of planets (classical count excluding dwarf planets) + PlanetCount = 8 + + # Expected list of planet names in order from the Sun + PlanetNames = @('Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune') +} diff --git a/tests/3-Advanced/Planets/Planets.Tests.ps1 b/tests/3-Advanced/Planets/Planets.Tests.ps1 new file mode 100644 index 00000000..9c8a8d0f --- /dev/null +++ b/tests/3-Advanced/Planets/Planets.Tests.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $Path +) + +BeforeAll { + . $Path +} + +Describe 'Planets Module Tests' { + + Context 'Planet Count' { + It 'Should return the expected number of planets' { + $expectedCount = $PlanetsSettings.PlanetCount + $actualCount = $PlanetsSettings.PlanetNames.Count + $actualCount | Should -Be $expectedCount + } + } + + Context 'Planet Names' { + It 'Should return the expected list of planet names' { + $expectedNames = $PlanetsSettings.PlanetNames + $actualNames = $PlanetsSettings.PlanetNames + $actualNames | Should -BeExactly $expectedNames + } + } +} diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index a570e4d1..00000000 --- a/tests/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Placeholder for tests - -Location for tests of the action.