diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..6afe2dbe --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,94 @@ +# Requirements + +- ComputerManagementDsc-specific guidelines and requirements override general project + guidelines and requirements. + +## Module Identity + +**ComputerManagementDsc** is a DSC Community PowerShell module providing Desired State +Configuration resources for Windows computer and OS settings. + +## Terminology + +- **Command**: Public command +- **Function**: Private function +- **Resource**: DSC class-based resource + +## Core Requirements + +- Instructions take precedence over existing code patterns. +- Always update the `Unreleased` section of `CHANGELOG.md` for every code change. +- Localize all user-visible strings using `$script:localizedData` keys; never use + hardcoded string literals in `Write-Verbose`, `Write-Error`, or exception messages. +- Check [`DscResource.Common`](https://github.com/dsccommunity/DscResource.Common/wiki) + before creating private helper functions. +- Use `New-InvalidOperationException`, `New-ArgumentException`, and similar helpers from + `DscResource.Common` instead of `throw`. +- Separate reusable logic into private functions. +- Add unit tests for all commands, functions, and resources. +- Add integration tests for all public commands and resources. + +## Resource Types + +This repository contains two types of DSC resources: + +- **MOF-based resources** (`source/DSCResources/DSC_/`) — implement + `Get-TargetResource`, `Test-TargetResource`, and `Set-TargetResource` +- **Class-based resources** (`source/Classes/..ps1`) — inherit `ResourceBase` + from `DscResource.Base`; implement `GetCurrentState()` and `Modify()` +- Prefer class-based resources; use MOF-based only when required + (e.g. WMF 4.0 support). + +## File Organization + +- Class-based resources: `source/Classes/..ps1` +- MOF-based resources: `source/DSCResources/DSC_/DSC_.psm1` +- Resource enums: `source/Enum/..ps1` +- Unit tests (MOF): `tests/Unit/DSC_.Tests.ps1` +- Unit tests (class): `tests/Unit/Classes/.Tests.ps1` +- Integration tests: `tests/Integration/*.Tests.ps1` (class resources in `tests/Integration/Classes/`) +## Naming Conventions + +- MOF-based resources: `DSC_` prefix on all files and all exported + functions (e.g. `DSC_TimeZone.psm1`; `Get-TargetResource` is exported via + `*-TargetResource`). +- Class-based resources: PascalCase class name; file prefix + `..ps1` where the number is the dependency + group (e.g. `020.PSResourceRepository.ps1`). + +## Build & Test Workflow + +- Never use VS Code tasks; always use PowerShell scripts via terminal from the + repository root. +- Setup build and test environment (once per `pwsh` session): + `./build.ps1 -Tasks noop` +- Build project before running tests: `./build.ps1 -Tasks build` +- Run all unit tests: `Invoke-Pester -Path 'tests/Unit' -Output Detailed` +- Run a specific MOF resource unit test: + `Invoke-Pester -Path 'tests/Unit/DSC_.Tests.ps1' -Output Detailed` +- Run a specific class resource unit test: + `Invoke-Pester -Path 'tests/Unit/Classes/.Tests.ps1' -Output Detailed` +- Never run integration tests locally. +- Run unit tests in a new `pwsh` session after changing class-based resources. + +## Instruction Files + +Read the following instruction files before working on the corresponding areas: + +- `.github/instructions/dsc-community-powershell.instructions.md` — PowerShell style +- `.github/instructions/dsc-community-pester.instructions.md` — Pester test style +- `.github/instructions/dsc-community-unit-tests.instructions.md` — unit test setup and patterns +- `.github/instructions/dsc-community-integration-tests.instructions.md` — integration test patterns +- `.github/instructions/dsc-community-mof-resource.instructions.md` — MOF resource implementation +- `.github/instructions/dsc-community-class-resource.instructions.md` — class-based resource implementation +- `.github/instructions/dsc-community-localization.instructions.md` — localized string conventions +- `.github/instructions/dsc-community-changelog.instructions.md` — changelog format +- `.github/instructions/dsc-community-markdown.instructions.md` — markdown style + +## Key External References + +- [DSC Community Guidelines](https://dsccommunity.org/guidelines/) +- [DSC Community Blog](https://dsccommunity.org/blog/) +- [DscResource.Common](https://github.com/dsccommunity/DscResource.Common) +- [DscResource.Base](https://github.com/dsccommunity/DscResource.Base) +- [DscResource.Test](https://github.com/dsccommunity/DscResource.Test) diff --git a/.github/instructions/dsc-community-changelog.instructions.md b/.github/instructions/dsc-community-changelog.instructions.md new file mode 100644 index 00000000..b4f1ca43 --- /dev/null +++ b/.github/instructions/dsc-community-changelog.instructions.md @@ -0,0 +1,26 @@ +--- +description: Guidelines for a consistent changelog. +applyTo: "CHANGELOG.md" +version: 1.0.0 +--- + +# Changelog Guidelines + +- Always update the `## [Unreleased]` section in `CHANGELOG.md` +- One section per change type (`Added`, `Changed`, `Fixed`) under `## [Unreleased]` +- Use Keep a Changelog format +- Describe changes briefly; ≤2 items per change type +- Reference issues using format + ` - Fixes [Issue #](https://github.com/dsccommunity/ComputerManagementDsc/issues/)` + - capital `I` in `Issue`; `Fixes` prefix; full stop after closing parenthesis +- No empty lines between list items in same section +- No duplicate sections or items in `## [Unreleased]`; skip if entry already exists +- Mark breaking changes with `BREAKING CHANGE:` prefix on the entry in `### Changed` + or `### Fixed` +- Group multiple changes for the same resource using two-level indentation: + + ```markdown + - ResourceName + - First change description for this resource - Fixes [Issue #](https://github.com/dsccommunity/ComputerManagementDsc/issues/) + - Second change description for this resource - Fixes [Issue #](https://github.com/dsccommunity/ComputerManagementDsc/issues/) + ``` diff --git a/.github/instructions/dsc-community-class-resource.instructions.md b/.github/instructions/dsc-community-class-resource.instructions.md new file mode 100644 index 00000000..a9e25d03 --- /dev/null +++ b/.github/instructions/dsc-community-class-resource.instructions.md @@ -0,0 +1,168 @@ +--- +description: Guidelines for implementing Desired State Configuration (DSC) class-based resources. +applyTo: "source/[cC]lasses/**/*.ps1" +version: 1.0.0 +--- + +# DSC Class-Based Resource Guidelines + +**Applies to:** Classes with `[DscResource(...)]` decoration only. + +## Requirements + +- File: `source/Classes/..ps1` +- Decoration: `[DscResource(RunAsCredential = 'Optional')]` (use `'Mandatory'` if required) +- Inherit `ResourceBase` (DscResource.Base) +- `$this.localizedData` auto-populated by `ResourceBase` from localization file +- Value-type properties: use `[Nullable[{FullTypeName}]]` (e.g. `[Nullable[System.Int32]]`) + +## Required Constructor + +```powershell +MyResourceName () : base ($PSScriptRoot) +{ + # Property names where state cannot be enforced, e.g. IsSingleInstance, Force + $this.ExcludeDscProperties = @() +} +``` + +## Required Method Pattern + +```powershell +[MyResourceName] Get() +{ + # Call base implementation to get current state + $currentState = ([ResourceBase] $this).Get() + + # If needed, post-processing on current state that cannot be handled by GetCurrentState() + + return $currentState +} + +[System.Boolean] Test() +{ + # Call base implementation to test current state + $inDesiredState = ([ResourceBase] $this).Test() + + # If needed, post-processing on test result that cannot be handled by base Test() + + return $inDesiredState +} + +[void] Set() +{ + # Call base implementation to set desired state + ([ResourceBase] $this).Set() + + # If needed, additional state changes that cannot be handled by Modify() +} + +hidden [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) +{ + # Always return current state as hashtable; $properties contains key properties +} + +hidden [void] Modify([System.Collections.Hashtable] $properties) +{ + # Always set desired state; $properties contains those that must change state +} +``` + +## Optional Method Pattern + +```powershell +hidden [void] AssertProperties([System.Collections.Hashtable] $properties) +{ + # Validate user-provided properties; $properties contains user-assigned values +} + +hidden [void] NormalizeProperties([System.Collections.Hashtable] $properties) +{ + # Normalize user-provided properties; $properties contains user-assigned values +} +``` + +## Required Comment-based Help + +Add to `.DESCRIPTION` section: + +- `## Requirements`: List minimum requirements +- `## Known issues`: Critical issues + pattern: + `All issues are not listed here, see [all open issues](https://github.com/dsccommunity/ComputerManagementDsc/issues?q=is%3Aissue+is%3Aopen+in%3Atitle+{ResourceName}).` + +## Error Handling for Classes + +- Use `try/catch` blocks to handle exceptions +- Do not use `throw` for terminating errors; use `New-*Exception` commands: + - [`New-InvalidDataException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91InvalidDataException) + - [`New-ArgumentException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91ArgumentException) + - [`New-InvalidOperationException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91InvalidOperationException) + - [`New-ObjectNotFoundException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91ObjectNotFoundException) + - [`New-InvalidResultException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91InvalidResultException) + - [`New-NotImplementedException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91NotImplementedException) + +## Property Attributes + +- `[DscProperty(Key)]` — key (identity) properties; always mark at least one per resource +- `[DscProperty(Mandatory)]` — required (non-key) properties +- `[DscProperty()]` — optional properties +- `[DscProperty(NotConfigurable)]` — read-only / computed properties not configurable by the + user (e.g. `Reasons`) +- `[ValidateSet('Value1', 'Value2')]` — restrict allowed values on string properties +- For Enum-typed properties, set a default value: + + ```powershell + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + ``` + +## Machine Configuration Compliance (`Reasons`) + +Resources that support Azure Policy / Machine Configuration compliance auditing must include +a `Reasons` property using the `CMReason` helper class: + +```powershell +[DscProperty(NotConfigurable)] +[CMReason[]] +$Reasons +``` + +## Class-Level Comment-Based Help + +Place a comment block **above** the `[DscResource()]` decoration with: + +- `.SYNOPSIS` — one-line description of the resource +- `.PARAMETER` — one entry per DSC property (name + description) +- `.EXAMPLE` — at least one `Invoke-DscResource` usage example + +```powershell +<# + .SYNOPSIS + A resource to manage... + + .PARAMETER Ensure + Specifies whether the resource should be Present or Absent. + + .PARAMETER Name + Specifies the name. + + .EXAMPLE + Invoke-DscResource -Name 'MyResource' -ModuleName 'MyModule' -Method 'Get' -Property @{ + Name = 'Value' + Ensure = 'Present' + } +#> +[DscResource()] +class MyResource : ResourceBase +``` + +## Localized Data in Class Resources + +- Strings are loaded automatically by `ResourceBase` when passing `$PSScriptRoot` to the + base constructor: `MyResource () : base ($PSScriptRoot)` +- Access localized strings via `$this.localizedData.KeyName` (not `$script:localizedData`) + + ```powershell + Write-Verbose -Message ($this.localizedData.GetTargetResourceMessage -f $this.Name) + ``` diff --git a/.github/instructions/dsc-community-integration-tests.instructions.md b/.github/instructions/dsc-community-integration-tests.instructions.md new file mode 100644 index 00000000..10a24044 --- /dev/null +++ b/.github/instructions/dsc-community-integration-tests.instructions.md @@ -0,0 +1,148 @@ +--- +description: Guidelines for implementing integration tests. +applyTo: "tests/[iI]ntegration/**/*.[Tt]ests.ps1" +--- + +# Integration Tests Guidelines + +## Requirements +- Location (MOF resources): `tests/Integration/DSC_*.Tests.ps1` (plus a companion `.config.ps1`) +- Location (class resources): `tests/Integration/Classes/*.Tests.ps1` +- No mocking - real environment only +- Cover all scenarios and code paths +- Use `Get-ComputerName` for computer names in CI +- Avoid `ExpectedMessage` for `Should -Throw` assertions +- Only run integration tests in CI unless explicitly instructed. +- Call commands with `-Force` parameter where applicable (avoids prompting). +- Use `-ErrorAction 'Stop'` on commands so failures surface immediately + +## Required Setup Block + +```powershell +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:moduleName = '{MyModuleName}' + + Import-Module -Name $script:moduleName -ErrorAction 'Stop' +} +``` + +## DSC Resource Test Variables + +Define these variables in **both** `BeforeDiscovery` (for discovery-phase use) and `BeforeAll` +(for runtime use): + +```powershell +$script:dscModuleName = 'ComputerManagementDsc' +$script:dscResourceName = 'DSC_{ResourceName}' # for MOF resources +$script:skipIntegrationTests = $false +``` + +## Required Test Environment Setup (MOF Resources) + +```powershell +BeforeAll { + $script:dscModuleName = 'ComputerManagementDsc' + $script:dscResourceName = 'DSC_{ResourceName}' + + $script:testEnvironment = Initialize-TestEnvironment ` + -DSCModuleName $script:dscModuleName ` + -DSCResourceName $script:dscResourceName ` + -ResourceType 'Mof' ` + -TestType 'Integration' + + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '..\TestHelpers\CommonTestHelper.psm1') +} + +AfterAll { + Restore-TestEnvironment -TestEnvironment $script:testEnvironment +} +``` + +## Config File Pattern + +- Name the companion config file `{DSC_ResourceName}.config.ps1` (or `.Config.ps1`) +- Dot-source it in the `Describe BeforeAll`: + + ```powershell + $configFile = Join-Path -Path $PSScriptRoot -ChildPath "$($script:dscResourceName).config.ps1" + . $configFile -Verbose -ErrorAction Stop + ``` + +## Configuration Naming Convention + +Configuration functions must follow the pattern `DSC_{ResourceName}_{Purpose}_Config`: + +```powershell +Configuration DSC_TimeZone_SetTimeZone_Config { ... } +Configuration DSC_SmbShare_CreateShare1_Config { ... } +Configuration DSC_SmbShare_Cleanup_Config { ... } +``` + +## Prerequisites and Cleanup Configurations + +When a test requires external state (users, folders, shares, registry keys), define: + +- A `_Prerequisites_Config` configuration at the start of the test suite +- A `_Cleanup_Config` configuration at the end of the test suite + +## Standard Test Sequence + +Each configuration `Context` block must contain these `It` tests in order: + +1. `'Should compile the MOF without throwing'` — compile with `$TestDrive` as output: + + ```powershell + & $configName -OutputPath $TestDrive -ConfigurationData $configData + ``` + +2. `'Should apply the MOF without throwing'` — reset LCM then start configuration: + + ```powershell + Reset-DscLcm + Start-DscConfiguration -Path $TestDrive -ComputerName 'localhost' -Wait -Verbose -Force -ErrorAction Stop + ``` + +3. `'Should be able to call Get-DscConfiguration without throwing'`: + + ```powershell + Get-DscConfiguration -Verbose -ErrorAction Stop + ``` + +4. `'Should have set the resource and all the parameters should match'` — property assertions + +5. *(optional)* `'Should return $true when Test-DscConfiguration is run'`: + + ```powershell + Test-DscConfiguration -Verbose | Should -BeTrue + ``` + +## Key Rules + +- Always call `Reset-DscLcm` immediately before `Start-DscConfiguration` +- Use `$TestDrive` as the `-OutputPath` when compiling MOF configurations +- Use `'localhost'` as `ComputerName` in `Start-DscConfiguration` diff --git a/.github/instructions/dsc-community-localization.instructions.md b/.github/instructions/dsc-community-localization.instructions.md new file mode 100644 index 00000000..9f69155a --- /dev/null +++ b/.github/instructions/dsc-community-localization.instructions.md @@ -0,0 +1,98 @@ +--- +description: Guidelines for implementing localization. +applyTo: "source/**/*.{ps1,psm1}" +version: 1.0.0 +--- + +# Localization Guidelines + +## Requirements + +- Localize all `Write-Debug`, `Write-Verbose`, `Write-Error`, `Write-Warning`, and + `$PSCmdlet.ThrowTerminatingError()` messages; never use hardcoded strings +- Assume `$script:localizedData` is available + +## String Files + +- MOF-based resources: + `source/DSCResources/DSC_/en-US/DSC_.strings.psd1` +- Class-based resources: `source/en-US/.strings.psd1` +- Module-level strings: `source/en-US/.strings.psd1` + +## Key Naming Patterns + +- Format: `Verb_FunctionName_Action` (underscore separators), + e.g. `Get_TimeZone_GettingCurrentState` + +## String Format + +```powershell +ConvertFrom-StringData @' + KeyName = Message with {0} placeholder. (PREFIX0001) +'@ +``` + +## String IDs + +- Format: `(PREFIX####)` +- PREFIX: First letter of each word in class or function name + (e.g. `Get-TargetResource` for `DSC_TimeZone` → `GTR`, `PSResourceRepository` → `PRR`) +- Number: Sequential from 0001 + +## Usage + +```powershell +Write-Verbose -Message ($script:localizedData.KeyName -f $value1) +``` + +## Loading Mechanism by Resource Type + +### MOF-based resources + +At module scope, after importing `DscResource.Common`: + +```powershell +$script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US' +``` + +Access strings as `$script:localizedData.KeyName`. + +### Class-based resources + +Pass `$PSScriptRoot` to the `ResourceBase` base constructor in the class constructor: + +```powershell +MyResourceName () : base ($PSScriptRoot) +{ + ... +} +``` + +The `ResourceBase` class loads the strings automatically. +Access strings as `$this.localizedData.KeyName` — **not** `$script:localizedData`: + +```powershell +Write-Verbose -Message ($this.localizedData.GetTargetResourceMessage -f $this.Name) +``` + +## Error Messages + +Pass localized error strings to `DscResource.Common` exception helpers rather than calling +`$PSCmdlet.ThrowTerminatingError()` directly: + +```powershell +$errorMessage = $script:localizedData.SomeErrorKey -f $value +New-InvalidOperationException -Message $errorMessage +New-ArgumentException -ArgumentName 'ParameterName' -Message $errorMessage +``` + +## String File Header + +Optionally include `# culture="en-US"` as the first line of a `.strings.psd1` file: + +```powershell +# culture="en-US" +ConvertFrom-StringData -StringData @' + KeyName = Message text. (PREFIX0001) +'@ +``` diff --git a/.github/instructions/dsc-community-markdown.instructions.md b/.github/instructions/dsc-community-markdown.instructions.md new file mode 100644 index 00000000..ab849ae0 --- /dev/null +++ b/.github/instructions/dsc-community-markdown.instructions.md @@ -0,0 +1,24 @@ +--- +description: Guidelines for writing and maintaining Markdown documentation. +applyTo: "**/*.md" +version: 1.0.0 +--- + +# Markdown Style Guidelines + +- Wrap lines at word boundaries when over 80 characters (except tables/code blocks, + badge/shield image lines, and URLs inside markdown links) +- Use 2 spaces for indentation +- Use `1.` for all items in ordered lists (1/1/1 numbering style) +- Use `-` for all unordered list items (never `*` or `+`) +- Disable `MD013` rule by adding a comment for tables/code blocks exceeding 80 characters +- Empty lines required before/after code blocks and headings (except before line 1) +- Escape backslashes in file paths only (not in code blocks) +- Code blocks must specify language identifiers + +## Text Formatting + +- Parameters: **bold** +- Values/literals: `inline code` +- Resource/module/product names: _italic_ +- Commands/files/paths: `inline code` diff --git a/.github/instructions/dsc-community-mof-resource.instructions.md b/.github/instructions/dsc-community-mof-resource.instructions.md new file mode 100644 index 00000000..fad60af5 --- /dev/null +++ b/.github/instructions/dsc-community-mof-resource.instructions.md @@ -0,0 +1,150 @@ +--- +description: Guidelines for implementing MOF DSC resources. +applyTo: "source/DSCResources/**/*.psm1" +version: 1.0.0 +--- + +# MOF-based Desired State Configuration (DSC) Resources Guidelines + +## Required Functions + +- Define: `Get-TargetResource`, `Set-TargetResource`, `Test-TargetResource` +- Export using `*-TargetResource` pattern + +## Function Return Types + +- `Get-TargetResource`: Must return hashtable with all resource properties +- `Test-TargetResource`: Must return boolean (`$true`/`$false`) +- `Set-TargetResource`: Must not return anything (void) + +## Parameter Guidelines + +- `Get-TargetResource`: Only include parameters needed to retrieve actual current state values +- `Get-TargetResource`: Remove non-mandatory parameters that are never used +- `Set-TargetResource` and `Test-TargetResource`: Must have identical parameters +- `Set-TargetResource` and `Test-TargetResource`: Unused mandatory parameters: Add + "Not used in ``" to help comment + +## Required Elements + +- Each function must include `Write-Verbose` at least once + - `Get-TargetResource`: Use verbose message starting with "Getting the current state of..." + - `Set-TargetResource`: Use verbose message starting with "Setting the desired state of..." + - `Test-TargetResource`: Use verbose message starting with + "Determining the current state of..." +- Use localized strings for all messages (`Write-Verbose`, `Write-Error`, etc.) +- Import localized strings using `Get-LocalizedData` at module top + +## Error Handling for MOF-based Resources + +- Use `try/catch` blocks to handle exceptions +- Do not use `throw` for terminating errors; use `New-*Exception` commands: + - [`New-InvalidDataException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91InvalidDataException) + - [`New-ArgumentException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91ArgumentException) + - [`New-InvalidOperationException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91InvalidOperationException) + - [`New-ObjectNotFoundException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91ObjectNotFoundException) + - [`New-InvalidResultException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91InvalidResultException) + - [`New-NotImplementedException`](https://github.com/dsccommunity/DscResource.Common/wiki/New%E2%80%91NotImplementedException) + +# MOF Resource Localization + +## File Structure + +- Create `en-US` folder in each resource directory +- Name strings file: `DSC_.strings.psd1` +- Use names returned from `Get-UICulture` for additional language folder names + +## String File Format + +- In `.strings.psd1` files, use underscores as word separators in localized string key + names (for multi-word keys) + +## Function Requirements + +- All three functions must include `[CmdletBinding()]` +- `Get-TargetResource` must declare `[OutputType([System.Collections.Hashtable])]` +- `Test-TargetResource` must declare `[OutputType([System.Boolean])]` +- `Set-TargetResource` omits `[OutputType()]` + +## Module Import Boilerplate + +At the top of every `.psm1`, import the required helper modules before loading localized data: + +```powershell +$modulePath = Join-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) ` + -ChildPath 'Modules' + +Import-Module -Name (Join-Path -Path $modulePath ` + -ChildPath (Join-Path -Path 'ComputerManagementDsc.Common' ` + -ChildPath 'ComputerManagementDsc.Common.psm1')) + +Import-Module -Name (Join-Path -Path $modulePath -ChildPath 'DscResource.Common') + +$script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US' +``` + +## Script-Scoped Module Variables + +Define constants and shared lookup tables at module scope using `$script:` prefix: + +```powershell +$script:registryKey = 'HKLM:\SOFTWARE\...' +$script:parameterNames = @('Param1', 'Param2') +``` + +## Reboot Signalling (`$global:DSCMachineStatus`) + +When a resource must signal that a reboot is required, use the pattern below. Include a +`SuppressRestart` parameter to allow callers to suppress the reboot signal. +Suppress the PSScriptAnalyzer warning at module top: + +```powershell +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', + Justification = 'DSC requires $global:DSCMachineStatus to signal a reboot.')] +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Script Analyzer does not understand Pester syntax.')] +param () + +# ...inside Set-TargetResource: +if (-not $SuppressRestart) +{ + $global:DSCMachineStatus = 1 +} +``` + +## Private Helper Functions + +Resource-specific helper functions may be defined in the `.psm1` file below the three +required functions. Extract logic to `ComputerManagementDsc.Common` only when it is +needed by multiple resources. + +## Preferred Comparison Helper + +Use `Test-DscParameterState` from `DscResource.Common` as the preferred mechanism for +comparing current vs. desired state in `Test-TargetResource`. + +## Mutual Exclusivity Validation + +Use `Assert-BoundParameter` from `DscResource.Common` to validate mutually exclusive +parameter combinations in `Set-TargetResource` and `Test-TargetResource`. + +## Schema (.schema.mof) Structure + +Every MOF resource requires a `.schema.mof` file in the same directory: + +- Declare `ClassVersion` and `FriendlyName` in the class qualifier line +- Inherit from `OMI_BaseResource` +- Qualifier types: + - `[Key]` — key (identity) property + - `[Required]` — mandatory input property + - `[Write]` — optional input property + - `[Read]` — output-only / computed property + +```mof +[ClassVersion("1.0.0"), FriendlyName("TimeZone")] +class DSC_TimeZone : OMI_BaseResource +{ + [Key, Description("Specifies the resource is a single instance.")] String IsSingleInstance; + [Required, Description("The desired time zone.")] String TimeZone; +}; +``` diff --git a/.github/instructions/dsc-community-pester.instructions.md b/.github/instructions/dsc-community-pester.instructions.md new file mode 100644 index 00000000..b1128109 --- /dev/null +++ b/.github/instructions/dsc-community-pester.instructions.md @@ -0,0 +1,213 @@ +--- +description: Guidelines for writing and maintaining tests using Pester. +applyTo: "**/*.[Tt]ests.ps1" +--- + +# Tests Guidelines + +## Core Requirements +- All public commands, private functions and classes must have unit tests +- All public commands and class-based resources must have integration tests +- Use Pester v5 syntax only +- Test code only inside `Describe` blocks +- Assertions only in `It` blocks +- Never test verbose messages, debug messages or parameter binding behavior +- Pass all mandatory parameters to avoid prompts + +## Requirements +- Inside `It` blocks, assign unused return objects to `$null` (unless part of pipeline) +- Tested entity must be called from within the `It` blocks +- Keep results and assertions in same `It` block +- Avoid try-catch-finally for cleanup, use `AfterAll` or `AfterEach` +- Avoid unnecessary remove/recreate cycles + +## Naming +- One `Describe` block per file matching the tested entity name +- `Context` descriptions start with 'When' +- `It` descriptions start with 'Should', must not contain 'when' +- Mock variables prefix: 'mock' + +## Structure & Scope +- Public commands: Never use `InModuleScope` (unless retrieving localized strings or creating an object using an internal class) +- Private functions/class resources: Always use `InModuleScope` +- Each class method = separate `Context` block +- Each scenario = separate `Context` block +- Use nested `Context` blocks for complex scenarios +- Mocking in `BeforeAll` (`BeforeEach` only when required) +- Setup/teardown in `BeforeAll`,`BeforeEach`/`AfterAll`,`AfterEach` close to usage +- Spacing between blocks, arrange, act, and assert for readability + +## Syntax Rules +- PascalCase: `Describe`, `Context`, `It`, `Should`, `BeforeAll`, `BeforeEach`, `AfterAll`, `AfterEach` +- Use `-BeTrue`/`-BeFalse` never `-Be $true`/`-Be $false`/`-Contain $true`/`-Contain $false` +- Never use `Assert-MockCalled`, use `Should -Invoke` instead +- Never add an empty `-MockWith` block +- Omit `-MockWith` when returning `$null` +- Set `$PSDefaultParameterValues` for `Mock:ModuleName`, `Should:ModuleName`, `InModuleScope:ModuleName` +- Omit `-ModuleName` parameter on Pester commands +- Never use `Mock` inside `InModuleScope`-block +- Never use `param()`-block inside `-MockWith` scriptblocks, parameters are auto-bound +- In `InModuleScope` tests, add `Set-StrictMode -Version 1.0` immediately before invoking the tested function +- Use `Should -Invoke -Exactly -Times -Scope It` for call-count assertions + - Assert calls inside the `It` block; do not assert call counts across an entire `Describe` or `Context` + +## File Organization +- MOF resources: `tests/Unit/DSC_{ResourceName}.Tests.ps1` +- Class resources: `tests/Unit/Classes/{ClassName}.Tests.ps1` +- Common module tests: `tests/Unit/ComputerManagementDsc.Common.Tests.ps1` + +## Data-Driven Tests (Test Cases) +- Define `-ForEach` variables in separate `BeforeDiscovery` (close to usage) +- `-ForEach` allowed on `Context` and `It` blocks +- Never add `param()` inside Pester blocks when using `-ForEach` +- Access test case properties directly: `$PropertyName` + +## Best Practices +- Cover all scenarios and code paths +- Use `BeforeEach` and `AfterEach` sparingly +- Use `$PSDefaultParameterValues` only for Pester commands (`Describe`, `Context`, `It`, `Mock`, `Should`, `InModuleScope`) + +## File Boilerplate + +Every test file must begin with: + +```powershell +# Suppressing this rule because Script Analyzer does not understand Pester's syntax. +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () +``` + +## Describe Block Naming and Tags + +- MOF resources: `'DSC_{ResourceName}\{FunctionName}'` with a tag matching the function: + + ```powershell + Describe 'DSC_TimeZone\Get-TargetResource' -Tag 'Get' { ... } + Describe 'DSC_TimeZone\Set-TargetResource' -Tag 'Set' { ... } + Describe 'DSC_TimeZone\Test-TargetResource' -Tag 'Test' { ... } + ``` + +- Class-based resources: `'{ClassName}\{MethodName}()'` with a matching tag: + + ```powershell + Describe 'PSResourceRepository\GetCurrentState()' -Tag 'GetCurrentState' { ... } + Describe 'PSResourceRepository\Modify()' -Tag 'Modify' { ... } + Describe 'PSResourceRepository\AssertProperties()' -Tag 'AssertProperties' { ... } + ``` + +- Standard tags: `Get`, `Set`, `Test`, `Modify`, `GetCurrentState`, `AssertProperties`, + `Private` + +## File Organization — DSC Resources + +- MOF resource unit tests: `tests/Unit/DSC_{ResourceName}.Tests.ps1` +- Integration tests: `tests/Integration/DSC_{ResourceName}.Integration.Tests.ps1` + (note `_Integration` suffix on the `Describe` block name as well) + +## Class Resource Method Mocking + +Class methods cannot be mocked with `Mock`. Use `Add-Member -MemberType ScriptMethod`: + +```powershell +$mockInstance = [MyResource] @{ Name = 'Test' } | + Add-Member -Force -MemberType 'ScriptMethod' -Name 'GetCurrentState' -Value { + return [System.Collections.Hashtable] @{ Name = 'Test'; Ensure = 'Present' } + } -PassThru | + Add-Member -Force -MemberType 'ScriptMethod' -Name 'AssertProperties' -Value { + return + } -PassThru +``` + +Methods commonly stubbed: `GetCurrentState`, `AssertProperties`, `Compare`, `Modify`. + +## Script-Scoped Call Counter (Class Methods) + +Since `Should -Invoke` cannot track class method calls, use a `$script:` counter: + +```powershell +BeforeAll { + InModuleScope -ScriptBlock { + $script:mockInstance = [MyResource] @{ Name = 'Test' } | + Add-Member -Force -MemberType 'ScriptMethod' -Name 'Modify' -Value { + $script:mockMethodModifyCallCount += 1 + } -PassThru + } +} + +BeforeEach { + InModuleScope -ScriptBlock { + $script:mockMethodModifyCallCount = 0 + } +} + +It 'Should call Modify() exactly once' { + InModuleScope -ScriptBlock { + $script:mockInstance.Set() + $script:mockMethodModifyCallCount | Should -Be 1 + } +} +``` + +## `InModuleScope -Parameters $_` for Data-Driven Tests + +When using `-TestCases` or `-ForEach`, pass parameters into `InModuleScope` with +`-Parameters $_`: + +```powershell +It 'Should set the correct value for ' -TestCases $testCases { + InModuleScope -Parameters $_ -ScriptBlock { + Set-StrictMode -Version 1.0 + { Set-TargetResource -Property $Property } | Should -Not -Throw + } +} +``` + +## Cross-Module Mocking + +To mock a function from a different module (e.g., a helper module), pass `-ModuleName` +explicitly on `Mock` even when `$PSDefaultParameterValues` is set: + +```powershell +Mock -ModuleName 'ComputerManagementDsc.Common' -CommandName 'Get-TimeZoneId' -MockWith { + return 'Pacific Standard Time' +} +``` + +## `ParameterFilter` with Catch-All + +When mocking the same command for different parameter combinations, add a final catch-all +mock (no filter) that throws to detect unexpected calls: + +```powershell +Mock -CommandName Get-RegistryPropertyValue ` + -ParameterFilter { $Name -eq 'FilterAdministratorToken' } ` + -MockWith { return 1 } + +Mock -CommandName Get-RegistryPropertyValue ` + -MockWith { throw 'Called with unexpected parameter values.' } +``` + +## `BeforeDiscovery` Inside `Describe` for Test Cases + +Define test case arrays in a `BeforeDiscovery` block inside the `Describe` block so they +are available during the discovery phase: + +```powershell +Describe 'MyResource\Set()' -Tag 'Set' { + BeforeDiscovery { + $testCases = @( + @{ Value = 'A'; Expected = 1 } + @{ Value = 'B'; Expected = 2 } + ) + } + + Context 'When value is ' -ForEach $testCases { + It 'Should return ' { + InModuleScope -Parameters $_ -ScriptBlock { + Set-StrictMode -Version 1.0 + # ... + } + } + } +} +``` diff --git a/.github/instructions/dsc-community-powershell.instructions.md b/.github/instructions/dsc-community-powershell.instructions.md new file mode 100644 index 00000000..82da9462 --- /dev/null +++ b/.github/instructions/dsc-community-powershell.instructions.md @@ -0,0 +1,327 @@ +--- +description: Guidelines for writing PowerShell scripts and modules. +applyTo: "{**/*.ps1,**/*.psm1,**/*.psd1}" +version: 1.0.0 +--- + +# PowerShell Guidelines + +## Naming + +- Use descriptive names (3+ characters, no abbreviations) +- Functions: PascalCase with Verb-Noun format using approved verbs +- Parameters: PascalCase +- Variables: camelCase +- Keywords: lower-case +- Classes: PascalCase +- Include scope for script/global/environment variables: `$script:`, `$global:`, `$env:` + +## File Naming + +- Class files: `###.ClassName.ps1` format (e.g. `001.CMReason.ps1`, `020.PSResourceRepository.ps1`) + +## Formatting + +### Indentation & Spacing + +- Use 4 spaces (no tabs) +- One space around operators: `$a = 1 + 2` +- One space between type and variable: `[String] $name` +- One space between keyword and parenthesis: `if ($condition)` +- No spaces on empty lines +- Try to limit lines to 120 characters + +### Braces + +- Newline before opening brace (except variable assignments) +- One newline after opening brace +- Two newlines after closing brace (one if followed by another brace or continuation) + +### Quotes + +- Use single quotes unless variable expansion is needed: `'text'` vs `"text $variable"` + +### Arrays + +- Single line: `@('one', 'two', 'three')` +- Multi-line: each element on separate line with proper indentation +- Do not use the unary comma operator (`,`) in return statements to force an array + +### Hashtables + +- Empty: `@{}` +- Each property on separate line with proper indentation +- Properties: Use PascalCase + +### Comments + +- Single line: `# Comment` (capitalized, on own line) +- Multi-line: `<# Comment #>` format (opening and closing brackets on own line), and indent text +- No commented-out code + +### Comment-based Help + +- Always add comment-based help to all functions and scripts +- Sections required: SYNOPSIS, DESCRIPTION (40+ chars), PARAMETER, EXAMPLE +- Comment-based help indentation: keywords 4 spaces, text 8 spaces +- Include examples for all parameter sets and combinations +- INPUTS: List each pipeline-accepted type as inline code with a 1-line description. Repeat + keyword for each input type. If there are no inputs, specify `None.`. +- OUTPUTS: List each return type as inline code with a 1-line description. Repeat keyword for + each output type. Must match both `[OutputType()]` and actual returns. If there are no + outputs, specify `None.`. +- .NOTES: Include only if it conveys critical info (constraints, side effects, security, + version compatibility, breaking behavior). Keep to ≤2 short sentences. + +## Functions + +- Avoid aliases (use full command names) +- Avoid `Write-Host` (use `Write-Verbose`, `Write-Information`, etc.) +- Avoid `Write-Output` (use `return` instead) +- Avoid `ConvertTo-SecureString -AsPlainText` in production code +- Don't redefine reserved parameters (Verbose, Debug, etc.) +- Include a `Force` parameter for functions that use `$PSCmdlet.ShouldContinue` or + `$PSCmdlet.ShouldProcess` +- For state-changing functions, use `SupportsShouldProcess` + - Place ShouldProcess check immediately before each state-change + - Set `ConfirmImpact` to 'Low', 'Medium', or 'High' depending on risk + - `$PSCmdlet.ShouldProcess` must use required pattern + - Inside `$PSCmdlet.ShouldProcess` block, avoid using `Write-Verbose` +- Never use backtick as line continuation in production code +- Set `$ErrorActionPreference = 'Stop'` before commands using `-ErrorAction 'Stop'`; restore + previous value directly after invocation (do not use try-catch-finally) +- Use `[Alias()]` attribute for function aliases, never `Set-Alias` or `New-Alias` + +### Structure + +```powershell +<# + .SYNOPSIS + Brief description + + .DESCRIPTION + Detailed description + + .PARAMETER Name + Parameter description + + .INPUTS + TypeName1 + + Description1 + + .OUTPUTS + TypeName1 + + Description1 +#> +function Get-Something +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Name, + + [Parameter()] + [System.String] + $OptionalParam + ) + + # Implementation +} +``` + +### Requirements + +- Include `[CmdletBinding()]` on every function +- Parameter block at top +- Parameter block: `param ()` if empty, else opening/closing parentheses on own lines +- `[OutputType({return type})]` for functions with output; no output use `[OutputType()]` +- All parameters use `[Parameter()]` attribute; mandatory parameters use + `[Parameter(Mandatory = $true)]` +- Parameter attributes on separate lines +- Parameter type on line above parameter name +- Parameters separated by blank line +- Parameters should use full type name +- `ValueFromPipeline` must be consistent across all parameter sets declarations for the same + parameter + +## Output Streams + +- Never output sensitive data/secrets +- Use `Write-Debug` for: internal diagnostics; variable values/traces; developer-focused + details +- Use `Write-Verbose` for: high-level execution flow only; user-actionable information +- Use `Write-Information` for: user-facing status updates; important operational messages; + non-error state changes +- Use `Write-Warning` for: non-fatal issues requiring attention; deprecated functionality + usage; configuration problems that don't block execution +- Use `$PSCmdlet.ThrowTerminatingError()` for terminating errors (except for classes); use + relevant error category; in try-catch include exception with localized message +- Use `Write-Error` for non-terminating errors + - Always include `-Message` (localized string), `-Category` (relevant error category), + `-ErrorId` (unique ID matching localized string ID), `-TargetObject` (object causing error) + - In catch blocks, pass original exception using `-Exception` + - Always use `return` after `Write-Error` to avoid further processing + +## ShouldProcess Required Pattern + +- Ensure `$descriptionMessage` explains what will happen +- Ensure `$confirmationMessage` succinctly asks for confirmation +- Keep `$captionMessage` short and descriptive (no trailing `.`) + +```powershell +$descriptionMessage = $script:localizedData.FunctionName_Action_ShouldProcessDescription -f $param1, $param2 +$confirmationMessage = $script:localizedData.FunctionName_Action_ShouldProcessConfirmation -f $param1 +$captionMessage = $script:localizedData.FunctionName_Action_ShouldProcessCaption + +if ($PSCmdlet.ShouldProcess($descriptionMessage, $confirmationMessage, $captionMessage)) +{ + # state changing code +} +``` + +## Force Parameter Pattern + +```powershell +if ($Force.IsPresent -and -not $Confirm) +{ + $ConfirmPreference = 'None' +} +``` + +## Best Practices + +### Code Organization + +- Use named parameters in function calls +- Use splatting for long parameter lists +- Limit piping to one pipe per line +- Assign function results to variables rather than inline calls +- Return a single, consistent object type per function + - Return `$null` for no objects/non-terminating errors +- For most .NET types, use the `::new()` static method instead of `New-Object`, + e.g. `[System.DateTime]::new()` +- For error handling, use dedicated helper commands instead: + - Use `New-Exception` instead of `[System.Exception]::new(...)` + - Use `New-ErrorRecord` instead of `[System.Management.Automation.ErrorRecord]::new(...)` + +### Security & Safety + +- Use `PSCredential` for credentials +- Avoid hardcoded computer names; use cross-platform + [`Get-ComputerName`](https://github.com/dsccommunity/DscResource.Common/wiki/Get%E2%80%91ComputerName) + instead of `$env:COMPUTERNAME` +- Place `$null` on left side of comparisons +- Avoid empty catch blocks (instead use `-ErrorAction SilentlyContinue`) +- Don't use `Invoke-Expression` (use `&` operator) +- Use CIM commands instead of WMI commands + +### Variables + +- Avoid global variables (exception: `$global:DSCMachineStatus`) +- Use declared variables more than once +- Avoid unnecessary type declarations when type is clear +- Use full type name when type casting +- No default values for mandatory or switch parameters + +## File Rules + +- End files with only one blank line +- Use line endings based on `.gitattributes` policy +- Maximum two consecutive newlines +- No line shall have trailing whitespace + +## File Encoding + +- Use UTF-8 encoding (no BOM) for all files + +## Module Manifest + +- Don't use `NestedModules` for shared commands without `RootModule` + +## Closing Brace Comments + +In complex functions or long script blocks, add a trailing comment on the closing brace to +identify the construct it closes: + +```powershell +} # function Get-TargetResource +} # if ($Ensure -eq 'Present') +} # try +} # foreach ($item in $collection) +``` + +## Hashtable and Splatting Alignment + +When assigning multiple properties to a hashtable or building a splatting block, align the +`=` operators in a column for readability: + +```powershell +$returnValue = @{ + Name = $repository.Name + SourceLocation = $repository.SourceLocation + PublishLocation = $repository.PublishLocation + Ensure = 'Present' +} +``` + +## Class-Level Comment-Based Help + +For PowerShell class files, place comment-based help **above** the class declaration (and +any attributes) using `.SYNOPSIS`, one `.PARAMETER` entry per significant property, and +at least one `.EXAMPLE`: + +```powershell +<# + .SYNOPSIS + A class to manage... + + .PARAMETER Name + Specifies the name. + + .EXAMPLE + [MyClass]::new('Value') +#> +[DscResource()] +class MyClass { ... } +``` + +## Private Function Exemptions + +Private (unexported) helper functions do not require the full comment-based help set +(`[CmdletBinding()]`, `[OutputType()]`, `.DESCRIPTION`, `.INPUTS`, `.OUTPUTS`, `.EXAMPLE` +are optional). They require at minimum a `.SYNOPSIS` line. + +## `[SuppressMessageAttribute]` Usage + +When a PSScriptAnalyzer rule must be suppressed (e.g., `PSAvoidGlobalVars` for +`$global:DSCMachineStatus`, or `PSUseDeclaredVarsMoreThanAssignments` for Pester blocks), +add the attribute with a justification comment at the top of the file before `param ()`: + +```powershell +# Justification comment describing why the rule is suppressed. +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')] +param () +``` + +## DSC Resource Module Import Boilerplate + +At the top of every MOF resource `.psm1`, import helper modules before loading localized +data (adjust relative path depth as needed): + +```powershell +$modulePath = Join-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) ` + -ChildPath 'Modules' + +Import-Module -Name (Join-Path -Path $modulePath ` + -ChildPath (Join-Path -Path 'ComputerManagementDsc.Common' ` + -ChildPath 'ComputerManagementDsc.Common.psm1')) + +Import-Module -Name (Join-Path -Path $modulePath -ChildPath 'DscResource.Common') + +$script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US' +``` diff --git a/.github/instructions/dsc-community-unit-tests.instructions.md b/.github/instructions/dsc-community-unit-tests.instructions.md new file mode 100644 index 00000000..bb96d441 --- /dev/null +++ b/.github/instructions/dsc-community-unit-tests.instructions.md @@ -0,0 +1,299 @@ +--- +description: Guidelines for writing and maintaining unit tests using Pester. +applyTo: "tests/[Uu]nit/**/*.[Tt]ests.ps1" +--- + +# Unit Tests Guidelines + +- Test with localized strings: Use `InModuleScope -ScriptBlock { $script:localizedData.Key }` +- Mock files: Use `$TestDrive` variable (path to the test drive) +- All public commands require parameter set validation tests +- After modifying classes, always run tests in new session (for changes to take effect) + +## Test Setup Requirements + +Use this exact setup block before `Describe`: + +```powershell +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:moduleName = '{MyModuleName}' + + Import-Module -Name $script:moduleName -ErrorAction 'Stop' + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:moduleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') +} +``` + +## Required Test Templates + +### Parameter Set Validation +Single parameter set: +```powershell +It 'Should have the correct parameters in parameter set ' -ForEach @( + @{ + ExpectedParameterSetName = '{ParameterSetName}' # e.g. __AllParameterSets + ExpectedParameters = '[-Parameter1] [-Parameter2] []' + } +) { + $result = (Get-Command -Name 'CommandName').ParameterSets | + Where-Object -FilterScript { $_.Name -eq $ExpectedParameterSetName } | + Select-Object -Property @( + @{ Name = 'ParameterSetName'; Expression = { $_.Name } }, + @{ Name = 'ParameterListAsString'; Expression = { $_.ToString() } } + ) + $result.ParameterSetName | Should -Be $ExpectedParameterSetName + $result.ParameterListAsString | Should -Be $ExpectedParameters +} +``` + +Multiple parameter sets: Use same pattern with multiple hashtables in `-ForEach` array. + +### Parameter Properties +```powershell +It 'Should have ParameterName as a mandatory parameter' { + $parameterInfo = (Get-Command -Name 'CommandName').Parameters['ParameterName'] + $parameterInfo.Attributes.Mandatory | Should -BeTrue +} +``` + +## DSC Resource Unit Test Setup + +### MOF Resource `BeforeAll` / `AfterAll` + +```powershell +BeforeAll { + $script:dscModuleName = 'ComputerManagementDsc' + $script:dscResourceName = 'DSC_{ResourceName}' + + $script:testEnvironment = Initialize-TestEnvironment ` + -DSCModuleName $script:dscModuleName ` + -DSCResourceName $script:dscResourceName ` + -ResourceType 'Mof' ` + -TestType 'Unit' + + Import-Module -Name (Join-Path -Path $PSScriptRoot ` + -ChildPath '..\TestHelpers\CommonTestHelper.psm1') + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscResourceName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscResourceName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscResourceName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + Restore-TestEnvironment -TestEnvironment $script:testEnvironment + + Get-Module -Name $script:dscResourceName -All | Remove-Module -Force + Get-Module -Name 'CommonTestHelper' -All | Remove-Module -Force +} +``` + +### Class Resource `BeforeAll` / `AfterAll` + +```powershell +BeforeAll { + $script:dscModuleName = 'ComputerManagementDsc' + + Import-Module -Name $script:dscModuleName + + Import-Module -Name (Join-Path -Path $PSScriptRoot ` + -ChildPath '..\..\TestHelpers\CommonTestHelper.psm1') + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force + Get-Module -Name 'CommonTestHelper' -All | Remove-Module -Force +} +``` + +## Describe Block Naming, Tags, and `InModuleScope` + +- Use `Set-StrictMode -Version 1.0` as the **first line inside every `InModuleScope` + scriptblock** +- MOF resource `Describe` blocks: `'DSC_{ResourceName}\{FunctionName}'` with a tag: + + ```powershell + Describe 'DSC_TimeZone\Get-TargetResource' -Tag 'Get' { ... } + Describe 'DSC_TimeZone\Set-TargetResource' -Tag 'Set' { ... } + Describe 'DSC_TimeZone\Test-TargetResource' -Tag 'Test' { ... } + ``` + +- Class resource `Describe` blocks: `'{ClassName}\{MethodName}()'` with a tag: + + ```powershell + Describe 'PSResourceRepository\GetCurrentState()' -Tag 'GetCurrentState' { ... } + Describe 'PSResourceRepository\Modify()' -Tag 'Modify' { ... } + Describe 'PSResourceRepository\AssertProperties()' -Tag 'AssertProperties' { ... } + ``` + +- Standard tags: `Get`, `Set`, `Test`, `Modify`, `GetCurrentState`, `AssertProperties`, + `Private` + +## Mock Assertions + +Use `Should -Invoke` (Pester v5) for all mock call-count assertions: + +```powershell +Should -Invoke -CommandName Set-TimeZoneId -Exactly -Times 1 -Scope It +Should -Invoke -CommandName Get-TimeZoneId -Exactly -Times 0 -Scope It +``` + +## `ParameterFilter` with Catch-All + +When mocking the same command for different parameter values, add a final catch-all mock +(no filter) that throws to detect unexpected calls: + +```powershell +Mock -CommandName Get-RegistryPropertyValue ` + -ParameterFilter { $Name -eq 'ConsentPromptBehaviorAdmin' } ` + -MockWith { return 2 } + +Mock -CommandName Get-RegistryPropertyValue ` + -MockWith { throw 'Called with unexpected parameter values.' } +``` + +## Cross-Module Mocking + +To mock a function from a module other than the one set in `$PSDefaultParameterValues`, +pass `-ModuleName` explicitly: + +```powershell +Mock -ModuleName 'ComputerManagementDsc.Common' -CommandName 'Get-TimeZoneId' -MockWith { + return 'Pacific Standard Time' +} +``` + +## Class Resource Method Mocking + +Class methods cannot be mocked with `Mock`. Use `Add-Member -MemberType ScriptMethod`: + +```powershell +BeforeAll { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $script:mockInstance = [PSResourceRepository] @{ Name = 'PSGallery' } | + Add-Member -Force -MemberType 'ScriptMethod' -Name 'GetCurrentState' -Value { + return [System.Collections.Hashtable] @{ Name = 'PSGallery'; Ensure = 'Present' } + } -PassThru | + Add-Member -Force -MemberType 'ScriptMethod' -Name 'AssertProperties' -Value { + return + } -PassThru + } +} +``` + +## Script-Scoped Call Counter (Class Methods) + +Since `Should -Invoke` cannot track class method calls, use a `$script:` counter: + +```powershell +BeforeAll { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $script:mockInstance = [PSResourceRepository] @{ Name = 'Test' } | + Add-Member -Force -MemberType 'ScriptMethod' -Name 'Modify' -Value { + $script:mockMethodModifyCallCount += 1 + } -PassThru + } +} + +BeforeEach { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + $script:mockMethodModifyCallCount = 0 + } +} + +It 'Should call Modify() exactly once' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + $script:mockInstance.Set() + $script:mockMethodModifyCallCount | Should -Be 1 + } +} +``` + +## `InModuleScope -Parameters $_` for Data-Driven Tests + +When using `-TestCases` or `-ForEach`, pass the test case parameters into `InModuleScope`: + +```powershell +It 'Should set the correct value for ' -TestCases $testCases { + InModuleScope -Parameters $_ -ScriptBlock { + Set-StrictMode -Version 1.0 + { Set-UserAccountControlToNotificationLevel -NotificationLevel $NotificationLevel } | + Should -Not -Throw + } +} +``` + +## `BeforeDiscovery` Inside `Describe` for Test Cases + +Define test case arrays inside `BeforeDiscovery` within the `Describe` block so they are +available during the discovery phase: + +```powershell +Describe 'MyResource\Set-TargetResource' -Tag 'Set' { + BeforeDiscovery { + $testCases = @( + @{ NotificationLevel = 'AlwaysNotify'; ConsentPromptBehaviorAdmin = 2 } + @{ NotificationLevel = 'NotifyChanges'; ConsentPromptBehaviorAdmin = 5 } + ) + } + + Context 'When notification level is ' -ForEach $testCases { + It 'Should apply the correct registry value' { + InModuleScope -Parameters $_ -ScriptBlock { + Set-StrictMode -Version 1.0 + # ... + } + } + } +} +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..92a14296 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# ComputerManagementDsc — AI Agent Guide + +## Repository Overview + +**ComputerManagementDsc** is a PowerShell Desired State Configuration (DSC) resource module +for managing Windows computer and operating system settings. + +- **Target platform:** Windows Server 2012 R2 and later; Windows 8.1 and later +- **DSC engines:** DSC v2 (WMF 5.0, MOF-based) and DSC v3 (PowerShell classes) +- **Organization:** [DSC Community](https://dsccommunity.org/) + +## Resource Types + +This repository contains two types of DSC resources: + +| Type | Location | Pattern | +|------|----------|---------| +| MOF-based | `source/DSCResources/DSC_/` | WMF 5.0 compatible, function-based | +| Class-based | `source/Classes/..ps1` | PowerShell class, inherits `ResourceBase` | + +### MOF-based Resource Structure + +``` +source/DSCResources/DSC_/ + DSC_.psm1 # Resource implementation + DSC_.schema.mof # MOF schema definition + en-US/ + DSC_.strings.psd1 # Localized strings +``` + +Required functions: `Get-TargetResource`, `Test-TargetResource`, `Set-TargetResource` + +### Class-based Resource Structure + +``` +source/Classes/..ps1 # N = dependency group number +source/en-US/.strings.psd1 # Localized strings +``` + +Class resources inherit `ResourceBase` from `DscResource.Base`. Implement `GetCurrentState()` +and `Modify()`. Optionally implement `AssertProperties()` and `NormalizeProperties()`. + +## Build & Test Workflow + +### One-time setup (once per `pwsh` session) + +```powershell +./build.ps1 -Tasks noop +``` + +### Build before running tests + +```powershell +./build.ps1 -Tasks build +``` + +### Run all unit tests + +```powershell +Invoke-Pester -Path 'tests/Unit' -Output Detailed +``` + +### Run a specific MOF resource unit test + +```powershell +Invoke-Pester -Path 'tests/Unit/DSC_.Tests.ps1' -Output Detailed +``` + +### Run class resource unit tests + +```powershell +Invoke-Pester -Path 'tests/Unit/Classes/.Tests.ps1' -Output Detailed +``` + +> **NEVER run integration tests locally.** Integration tests require a full Windows +> environment configured as a DSC node and run only in CI. They may change system state. + +## Key Conventions + +- **Localized strings:** All `Write-Verbose`, `Write-Error`, and exception messages must use + `$script:localizedData` keys. Load strings with `Get-LocalizedData` at the top of each + `.psm1`. +- **DscResource.Common:** Check + [`DscResource.Common`](https://github.com/dsccommunity/DscResource.Common/wiki) before + writing new helper functions. Use `New-InvalidOperationException`, `New-ArgumentException`, + and similar helpers instead of `throw`. +- **CHANGELOG.md:** Every code change must add an entry to the `Unreleased` section. +- **New resources:** Prefer class-based resources; use MOF-based only when required. + +## External Resources + +- [DSC Community Guidelines](https://dsccommunity.org/guidelines/) +- [DSC Community Blog](https://dsccommunity.org/blog/) +- [DscResource.Common wiki](https://github.com/dsccommunity/DscResource.Common/wiki) +- [DscResource.Base](https://github.com/dsccommunity/DscResource.Base) +- [DscResource.Test](https://github.com/dsccommunity/DscResource.Test) + +## Detailed Guidance + +See `.github/instructions/` for detailed style guidelines on PowerShell, Pester, unit tests, +integration tests, MOF resources, class-based resources, localization, markdown, and changelog. diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c5e898..2cc34ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `AGENTS.md` at repository root with build/test workflow, resource type overview, + and key conventions for AI agents - Fixes [Issue #464](https://github.com/dsccommunity/ComputerManagementDsc/issues/464). +- Added `tests/AGENTS.md` with Pester v5 guidance, test location map, and required setup + block templates for MOF and class-based resource tests. +- Added `.github/copilot-instructions.md` with workspace-level GitHub Copilot instructions. +- Added `.github/instructions/` directory with instruction files covering PowerShell style, + Pester, unit tests, integration tests, MOF resources, class-based resources, + localization, changelog, and markdown guidelines. + ### Changed - `azure-pipelines.yml` diff --git a/README.md b/README.md index 9555660b..f3cefa10 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ For a full list of resources in ComputerManagementDsc and examples on their use, check out the [ComputerManagementDsc wiki](https://github.com/dsccommunity/ComputerManagementDsc/wiki). ## Requirements + ### Windows Management Framework 5.0 Required because this module now implements class-based resources. @@ -89,4 +90,5 @@ Management Framework 5.0 or above. ### PSResourceRepository -The resource `PSResourceRepository` requires that the PowerShell modules `PowerShellGet` and `PackageManagement` are already present on the target computer. +The resource `PSResourceRepository` requires that the PowerShell modules +`PowerShellGet` and `PackageManagement` are already present on the target computer. diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 00000000..7b397dad --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,167 @@ +# tests/ — AI Agent Guide + +## Test Framework + +Use [Pester](https://pester.dev/) v5. Pester 4 syntax is forbidden. + +### Pester 4 → 5 Key Changes + +| Pester 4 | Pester 5 | +|----------|----------| +| `Assert-MockCalled` | `Should -Invoke` | +| `Should -Be $true` | `Should -BeTrue` | +| `Should -Be $false` | `Should -BeFalse` | +| Script-level test case variables | `BeforeDiscovery` block | +| `-ModuleName` on each call | `$PSDefaultParameterValues` with `Mock:ModuleName` etc. | + +All Pester keywords (`Describe`, `Context`, `It`, `BeforeAll`, `AfterAll`, `BeforeEach`, +`AfterEach`) use PascalCase. + +## Test Locations + +| Type | Location | +|------|----------| +| MOF resource unit tests | `tests/Unit/DSC_.Tests.ps1` | +| Class resource unit tests | `tests/Unit/Classes/.Tests.ps1` | +| Integration tests | `tests/Integration/` (MOF: one `.config.ps1` + one `*.Tests.ps1`; class-based: `tests/Integration/Classes/`) | +## Required Setup Block + +### MOF Resource Unit Test + +```powershell +# Suppressing this rule because Script Analyzer does not understand Pester's syntax. +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + # Redirect all streams to $null + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'ComputerManagementDsc' + $script:dscResourceName = 'DSC_' + + $script:testEnvironment = Initialize-TestEnvironment ` + -DSCModuleName $script:dscModuleName ` + -DSCResourceName $script:dscResourceName ` + -ResourceType 'Mof' ` + -TestType 'Unit' + + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '..\TestHelpers\CommonTestHelper.psm1') + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscResourceName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscResourceName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscResourceName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + Restore-TestEnvironment -TestEnvironment $script:testEnvironment + + Get-Module -Name $script:dscResourceName -All | Remove-Module -Force + Get-Module -Name 'CommonTestHelper' -All | Remove-Module -Force +} +``` + +### Class-based Resource Unit Test + +```powershell +# Suppressing this rule because Script Analyzer does not understand Pester's syntax. +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + # Same DscResource.Test bootstrap as MOF unit tests above +} + +BeforeAll { + $script:dscModuleName = 'ComputerManagementDsc' + + Import-Module -Name $script:dscModuleName + + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '..\..\TestHelpers\CommonTestHelper.psm1') + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force + Get-Module -Name 'CommonTestHelper' -All | Remove-Module -Force +} +``` + +### Integration Test + +```powershell +# Suppressing this rule because Script Analyzer does not understand Pester's syntax. +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + # Same DscResource.Test bootstrap as unit tests above + + $script:dscModuleName = 'ComputerManagementDsc' + $script:dscResourceName = 'DSC_' + + $script:skipIntegrationTests = $false +} + +BeforeAll { + $script:dscModuleName = 'ComputerManagementDsc' + $script:dscResourceName = 'DSC_' + + $script:testEnvironment = Initialize-TestEnvironment ` + -DSCModuleName $script:dscModuleName ` + -DSCResourceName $script:dscResourceName ` + -ResourceType 'Mof' ` + -TestType 'Integration' + + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '..\TestHelpers\CommonTestHelper.psm1') +} + +AfterAll { + Restore-TestEnvironment -TestEnvironment $script:testEnvironment +} +``` + +## Running Tests + +```powershell +# All unit tests (new pwsh session recommended after class-based resource changes) +Invoke-Pester -Path 'tests/Unit' -Output Detailed + +# Specific MOF resource unit test +Invoke-Pester -Path 'tests/Unit/DSC_.Tests.ps1' -Output Detailed + +# Specific class resource unit test +Invoke-Pester -Path 'tests/Unit/Classes/.Tests.ps1' -Output Detailed +``` + +> **NEVER run integration tests locally.** Integration tests run only in CI and may change +> system state.