diff --git a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 new file mode 100644 index 000000000..6498d8dd1 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psd1 @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'Microsoft.PowerShell.DSC.psm1' + +# Version number of this module. +ModuleVersion = '0.0.1' + +# ID used to uniquely identify this module +GUID = 'f4a5e270-0e6b-4f6a-b08a-3a1d2c7e9b4d' + +# Author of this module +Author = 'Microsoft Corporation' + +# Company or vendor of this module +CompanyName = 'Microsoft Corporation' + +# Copyright statement for this module +Copyright = '(c) Microsoft Corporation. All rights reserved.' + +# Description of the functionality provided by this module +Description = 'Provides functionality to assist in Microsoft Desired State Configuration (DSC) operations.' + +# Minimum version of the PowerShell engine required by this module +PowerShellVersion = '5.1' + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = @( + 'Import-DscAdaptedResourceManifest' + 'Import-DscResourceManifest' + 'New-DscAdaptedResourceManifest' + 'New-DscPropertyOverride' + 'New-DscResourceManifest' + 'Update-DscAdaptedResourceManifest' +) + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = @() + +# Variables to export from this module +VariablesToExport = @() + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = @() + +PrivateData = @{ + PSData = @{ + ProjectUri = 'https://github.com/PowerShell/DSC' + } +} +} diff --git a/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 new file mode 100644 index 000000000..5e510da09 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Microsoft.PowerShell.DSC.psm1 @@ -0,0 +1,1290 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$ErrorActionPreference = 'Stop' + +$script:AdaptedResourceSchemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' +$script:ResourceManifestSchemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' +$script:JsonSchemaUri = 'https://json-schema.org/draft/2020-12/schema' +$script:DefaultAdapter = 'Microsoft.Adapter/PowerShell' + +#region Classes + +class DscAdaptedResourceManifestSchema { + [hashtable] $Embedded +} + +class DscAdaptedResourceManifest { + [string] $Schema + [string] $Type + [string] $Kind + [string] $Version + [string[]] $Capabilities + [string] $Description + [string] $Author + [string] $RequireAdapter + [string] $Path + [DscAdaptedResourceManifestSchema] $ManifestSchema + + [string] ToJson() { + $manifest = [ordered]@{ + '$schema' = $this.Schema + type = $this.Type + kind = $this.Kind + version = $this.Version + capabilities = $this.Capabilities + description = $this.Description + author = $this.Author + requireAdapter = $this.RequireAdapter + path = $this.Path + schema = [ordered]@{ + embedded = $this.ManifestSchema.Embedded + } + } + return $manifest | ConvertTo-Json -Depth 10 + } + + [hashtable] ToHashtable() { + return [ordered]@{ + '$schema' = $this.Schema + type = $this.Type + kind = $this.Kind + version = $this.Version + capabilities = $this.Capabilities + description = $this.Description + author = $this.Author + requireAdapter = $this.RequireAdapter + path = $this.Path + schema = [ordered]@{ + embedded = $this.ManifestSchema.Embedded + } + } + } +} + +class DscPropertyOverride { + [string] $Name + [string] $Description + [string] $Title + [hashtable] $JsonSchema + [string[]] $RemoveKeys + [object] $Required + + DscPropertyOverride() { + $this.JsonSchema = @{} + $this.RemoveKeys = @() + } +} + +class DscResourceManifestList { + [System.Collections.Generic.List[hashtable]] $AdaptedResources + [System.Collections.Generic.List[hashtable]] $Resources + [System.Collections.Generic.List[hashtable]] $Extensions + + DscResourceManifestList() { + $this.AdaptedResources = [System.Collections.Generic.List[hashtable]]::new() + $this.Resources = [System.Collections.Generic.List[hashtable]]::new() + $this.Extensions = [System.Collections.Generic.List[hashtable]]::new() + } + + [void] AddAdaptedResource([DscAdaptedResourceManifest]$Manifest) { + $this.AdaptedResources.Add($Manifest.ToHashtable()) + } + + [void] AddResource([hashtable]$Resource) { + $this.Resources.Add($Resource) + } + + [void] AddExtension([hashtable]$Extension) { + $this.Extensions.Add($Extension) + } + + [string] ToJson() { + $result = [ordered]@{} + + if ($this.AdaptedResources.Count -gt 0) { + $result['adaptedResources'] = @($this.AdaptedResources) + } + + if ($this.Resources.Count -gt 0) { + $result['resources'] = @($this.Resources) + } + + if ($this.Extensions.Count -gt 0) { + $result['extensions'] = @($this.Extensions) + } + + return $result | ConvertTo-Json -Depth 15 + } +} + +#endregion Classes + +#region Private functions + +function GetDscResourceTypeDefinition { + [CmdletBinding()] + [OutputType([System.Collections.Generic.List[hashtable]])] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + + foreach ($e in $errors) { + Write-Error "Parse error in '$Path': $($e.Message)" + } + + $allTypeDefinitions = $ast.FindAll( + { + $typeAst = $args[0] -as [System.Management.Automation.Language.TypeDefinitionAst] + return $null -ne $typeAst + }, + $false + ) + + $results = [System.Collections.Generic.List[hashtable]]::new() + + foreach ($typeDefinition in $allTypeDefinitions) { + foreach ($attribute in $typeDefinition.Attributes) { + if ($attribute.TypeName.Name -eq 'DscResource') { + $results.Add(@{ + TypeDefinitionAst = $typeDefinition + AllTypeDefinitions = $allTypeDefinitions + }) + break + } + } + } + + return $results +} + +function GetDscResourceCapability { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.MemberAst[]]$MemberAst + ) + + $capabilities = [System.Collections.Generic.List[string]]::new() + $availableMethods = @('get', 'set', 'setHandlesExist', 'whatIf', 'test', 'delete', 'export') + $methods = $MemberAst | Where-Object { + $_ -is [System.Management.Automation.Language.FunctionMemberAst] -and $_.Name -in $availableMethods + } + + foreach ($method in $methods.Name) { + switch ($method) { + 'Get' { $capabilities.Add('get') } + 'Set' { $capabilities.Add('set') } + 'Test' { $capabilities.Add('test') } + 'WhatIf' { $capabilities.Add('whatIf') } + 'SetHandlesExist' { $capabilities.Add('setHandlesExist') } + 'Delete' { $capabilities.Add('delete') } + 'Export' { $capabilities.Add('export') } + } + } + + return ($capabilities | Select-Object -Unique) +} + +function GetDscResourceProperty { + [CmdletBinding()] + [OutputType([System.Collections.Generic.List[hashtable]])] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.TypeDefinitionAst[]]$AllTypeDefinitions, + + [Parameter(Mandatory)] + [System.Management.Automation.Language.TypeDefinitionAst]$TypeDefinitionAst + ) + + $properties = [System.Collections.Generic.List[hashtable]]::new() + CollectAstProperty -AllTypeDefinitions $AllTypeDefinitions -TypeAst $TypeDefinitionAst -Properties $properties + return , $properties +} + +function CollectAstProperty { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.TypeDefinitionAst[]]$AllTypeDefinitions, + + [Parameter(Mandatory)] + [System.Management.Automation.Language.TypeDefinitionAst]$TypeAst, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[hashtable]]$Properties + ) + + foreach ($typeConstraint in $TypeAst.BaseTypes) { + $baseType = $AllTypeDefinitions | Where-Object { $_.Name -eq $typeConstraint.TypeName.Name } + if ($baseType) { + CollectAstProperty -AllTypeDefinitions $AllTypeDefinitions -TypeAst $baseType -Properties $Properties + } + } + + foreach ($member in $TypeAst.Members) { + $propertyAst = $member -as [System.Management.Automation.Language.PropertyMemberAst] + if (($null -eq $propertyAst) -or ($propertyAst.IsStatic)) { + continue + } + + $isDscProperty = $false + $isKey = $false + $isMandatory = $false + foreach ($attr in $propertyAst.Attributes) { + if ($attr.TypeName.Name -eq 'DscProperty') { + $isDscProperty = $true + foreach ($namedArg in $attr.NamedArguments) { + switch ($namedArg.ArgumentName) { + 'Key' { $isKey = $true } + 'Mandatory' { $isMandatory = $true } + } + } + } + } + + if (-not $isDscProperty) { + continue + } + + $typeName = if ($propertyAst.PropertyType) { + $propertyAst.PropertyType.TypeName.Name + } else { + 'string' + } + + # check if the type is an enum defined in the same file + $enumValues = $null + $enumAst = $AllTypeDefinitions | Where-Object { + $_.Name -eq $typeName -and $_.IsEnum + } + if ($enumAst) { + $enumValues = @($enumAst.Members | ForEach-Object { $_.Name }) + } + + $Properties.Add(@{ + Name = $propertyAst.Name + TypeName = $typeName + IsKey = $isKey + IsMandatory = $isMandatory -or $isKey + EnumValues = $enumValues + }) + } +} + +function ParseCommentBasedHelp { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$CommentText + ) + + # Strip the <# and #> delimiters + $text = $CommentText -replace '^\s*<#', '' -replace '#>\s*$', '' + + $result = @{ + Synopsis = '' + Description = '' + Parameters = @{} + } + + $keywordPattern = '(?mi)^\s*\.(?SYNOPSIS|DESCRIPTION|PARAMETER|EXAMPLE|NOTES|OUTPUTS|INPUTS|LINK|COMPONENT|ROLE|FUNCTIONALITY)[^\S\r\n]*(?.*)$' + + $keywordMatches = [regex]::Matches($text, $keywordPattern) + + if ($keywordMatches.Count -eq 0) { + return $result + } + + for ($i = 0; $i -lt $keywordMatches.Count; $i++) { + $keyword = $keywordMatches[$i].Groups['keyword'].Value.ToUpper() + $arg = $keywordMatches[$i].Groups['arg'].Value.Trim() + + $startIndex = $keywordMatches[$i].Index + $keywordMatches[$i].Length + $endIndex = if ($i + 1 -lt $keywordMatches.Count) { $keywordMatches[$i + 1].Index } else { $text.Length } + $content = $text.Substring($startIndex, $endIndex - $startIndex).Trim() + + switch ($keyword) { + 'SYNOPSIS' { + $result.Synopsis = $content + } + 'DESCRIPTION' { + $result.Description = $content + } + 'PARAMETER' { + if (-not [string]::IsNullOrWhiteSpace($arg)) { + $result.Parameters[$arg] = $content + } + } + } + } + + return $result +} + +function GetClassCommentBasedHelp { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + + $blockCommentTokens = @($tokens | Where-Object { + $_.Kind -eq [System.Management.Automation.Language.TokenKind]::Comment -and + $_.Text.StartsWith('<#') + }) + + $classDefinitions = $tokens | Where-Object { + $_.Kind -eq [System.Management.Automation.Language.TokenKind]::Class + } + + $result = @{} + + foreach ($classToken in $classDefinitions) { + $classLine = $classToken.Extent.StartLineNumber + + # Walk backward from the class keyword to find the nearest block comment, + # allowing for attributes and blank lines between the comment and class. + $nearestComment = $null + foreach ($commentToken in $blockCommentTokens) { + $gap = $classLine - $commentToken.Extent.EndLineNumber + if ($gap -ge 1 -and $gap -le 10) { + # Verify no other class keyword exists between this comment and the current class + $isValid = $true + foreach ($otherClass in $classDefinitions) { + if ($otherClass -ne $classToken -and + $otherClass.Extent.StartLineNumber -gt $commentToken.Extent.EndLineNumber -and + $otherClass.Extent.StartLineNumber -lt $classLine) { + $isValid = $false + break + } + } + if ($isValid -and ($null -eq $nearestComment -or + $commentToken.Extent.EndLineNumber -gt $nearestComment.Extent.EndLineNumber)) { + $nearestComment = $commentToken + } + } + } + + if ($null -eq $nearestComment) { + continue + } + + $parsed = ParseCommentBasedHelp -CommentText $nearestComment.Text + + if ($parsed.Synopsis -or $parsed.Description -or $parsed.Parameters.Count -gt 0) { + # Determine the class name from the token following 'class' + $classIndex = [array]::IndexOf($tokens, $classToken) + $className = $null + for ($i = $classIndex + 1; $i -lt $tokens.Count; $i++) { + if ($tokens[$i].Kind -eq [System.Management.Automation.Language.TokenKind]::Identifier) { + $className = $tokens[$i].Text + break + } + } + if ($className) { + $result[$className] = $parsed + } + } + } + + return $result +} + +function ConvertToJsonSchemaType { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$TypeName + ) + + switch ($TypeName) { + 'string' { return @{ type = 'string' } } + 'int' { return @{ type = 'integer' } } + 'int32' { return @{ type = 'integer' } } + 'int64' { return @{ type = 'integer' } } + 'long' { return @{ type = 'integer' } } + 'double' { return @{ type = 'number' } } + 'float' { return @{ type = 'number' } } + 'single' { return @{ type = 'number' } } + 'decimal' { return @{ type = 'number' } } + 'bool' { return @{ type = 'boolean' } } + 'boolean' { return @{ type = 'boolean' } } + 'switch' { return @{ type = 'boolean' } } + 'hashtable' { return @{ type = 'object' } } + 'datetime' { return @{ type = 'string'; format = 'date-time' } } + default { + # arrays like string[] or int[] + if ($TypeName -match '^(.+)\[\]$') { + $innerType = ConvertToJsonSchemaType -TypeName $Matches[1] + return @{ type = 'array'; items = $innerType } + } + # default to string for unknown types + return @{ type = 'string' } + } + } +} + +function BuildEmbeddedJsonSchema { + [CmdletBinding()] + [OutputType([ordered])] + param( + [Parameter(Mandatory)] + [string]$ResourceName, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[hashtable]]$Properties, + + [Parameter()] + [string]$Description, + + [Parameter()] + [hashtable]$ClassHelp + ) + + $schemaProperties = [ordered]@{} + $requiredList = [System.Collections.Generic.List[string]]::new() + + foreach ($prop in $Properties) { + $schemaProp = [ordered]@{} + + if ($prop.EnumValues) { + $schemaProp['type'] = 'string' + $schemaProp['enum'] = $prop.EnumValues + } else { + $jsonType = ConvertToJsonSchemaType -TypeName $prop.TypeName + foreach ($key in $jsonType.Keys) { + $schemaProp[$key] = $jsonType[$key] + } + } + + $schemaProp['title'] = $prop.Name + + if ($ClassHelp -and $ClassHelp.Parameters.ContainsKey($prop.Name)) { + $schemaProp['description'] = $ClassHelp.Parameters[$prop.Name] + } else { + $schemaProp['description'] = "The $($prop.Name) property." + } + + $schemaProperties[$prop.Name] = $schemaProp + + if ($prop.IsMandatory) { + $requiredList.Add($prop.Name) + } + } + + $schema = [ordered]@{ + '$schema' = $script:JsonSchemaUri + title = $ResourceName + type = 'object' + required = @($requiredList) + additionalProperties = $false + properties = $schemaProperties + } + + if (-not [string]::IsNullOrEmpty($Description)) { + $schema['description'] = $Description + } + + return $schema +} + +function ResolveModuleInfo { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $resolvedPath = Resolve-Path -LiteralPath $Path + $extension = [System.IO.Path]::GetExtension($resolvedPath) + $directory = [System.IO.Path]::GetDirectoryName($resolvedPath) + + if ($extension -eq '.psd1') { + $manifestData = Import-PowerShellDataFile -Path $resolvedPath + $moduleName = [System.IO.Path]::GetFileNameWithoutExtension($resolvedPath) + $version = if ($manifestData.ModuleVersion) { $manifestData.ModuleVersion } else { '0.0.1' } + $author = if ($manifestData.Author) { $manifestData.Author } else { '' } + $description = if ($manifestData.Description) { $manifestData.Description } else { '' } + + $rootModule = $manifestData.RootModule + if ([string]::IsNullOrEmpty($rootModule)) { + $rootModule = "$moduleName.psm1" + } + $scriptPath = Join-Path $directory $rootModule + $psd1RelativePath = [System.IO.Path]::GetFileName($resolvedPath) + + return @{ + ModuleName = $moduleName + Version = $version + Author = $author + Description = $description + ScriptPath = $scriptPath + Psd1Path = $psd1RelativePath + Directory = $directory + } + } + + # derive fileName from .ps1 or .psm1 + $moduleName = [System.IO.Path]::GetFileNameWithoutExtension($resolvedPath) + + # validate if .psd1 is there and use that + $psd1Path = Join-Path $directory "$moduleName.psd1" + if (Test-Path -LiteralPath $psd1Path) { + return ResolveModuleInfo -Path $psd1Path + } + + $fileName = [System.IO.Path]::GetFileName($resolvedPath) + + return @{ + ModuleName = $moduleName + Version = '0.0.1' + Author = '' + Description = '' + ScriptPath = [string]$resolvedPath + Psd1Path = $fileName + Directory = $directory + } +} + +function ConvertPSObjectToHashtable { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [object]$InputObject + ) + + if ($InputObject -is [System.Collections.IDictionary]) { + $result = [ordered]@{} + foreach ($key in $InputObject.Keys) { + $result[$key] = ConvertPSObjectToHashtable -InputObject $InputObject[$key] + } + return $result + } + + if ($InputObject -is [PSCustomObject]) { + $result = [ordered]@{} + foreach ($property in $InputObject.PSObject.Properties) { + $result[$property.Name] = ConvertPSObjectToHashtable -InputObject $property.Value + } + return $result + } + + if ($InputObject -is [System.Collections.IList]) { + $items = [System.Collections.Generic.List[object]]::new() + foreach ($item in $InputObject) { + $items.Add((ConvertPSObjectToHashtable -InputObject $item)) + } + return @($items) + } + + return $InputObject +} + +function ConvertToAdaptedResourceManifest { + [CmdletBinding()] + [OutputType([DscAdaptedResourceManifest])] + param( + [Parameter(Mandatory)] + [hashtable]$Hashtable + ) + + $manifest = [DscAdaptedResourceManifest]::new() + $manifest.Schema = $Hashtable['$schema'] + $manifest.Type = $Hashtable['type'] + $manifest.Kind = if ($Hashtable.Contains('kind')) { $Hashtable['kind'] } else { 'resource' } + $manifest.Version = $Hashtable['version'] + $manifest.Capabilities = if ($Hashtable.Contains('capabilities') -and $null -ne $Hashtable['capabilities']) { @($Hashtable['capabilities']) } else { [string[]]::new(0) } + $manifest.Description = if ($Hashtable.Contains('description')) { [string]$Hashtable['description'] } else { '' } + $manifest.Author = if ($Hashtable.Contains('author')) { [string]$Hashtable['author'] } else { '' } + $manifest.RequireAdapter = $Hashtable['requireAdapter'] + $manifest.Path = if ($Hashtable.Contains('path')) { [string]$Hashtable['path'] } else { '' } + + $schemaData = $Hashtable['schema'] + if ($schemaData) { + $embeddedSchema = if ($schemaData.Contains('embedded')) { $schemaData['embedded'] } else { $schemaData } + $manifest.ManifestSchema = [DscAdaptedResourceManifestSchema]@{ + Embedded = $embeddedSchema + } + } + + return $manifest +} + +#endregion Private functions + +#region Public functions + +<# + .SYNOPSIS + Creates adapted resource manifest objects from class-based PowerShell DSC resources. + + .DESCRIPTION + Parses the AST of a PowerShell file (.ps1, .psm1, or .psd1) to find class-based DSC + resources decorated with the [DscResource()] attribute. For each resource found, it + returns a DscAdaptedResourceManifest object that complies with the DSCv3 adapted + resource manifest JSON schema. + + The returned objects can be serialized to JSON using the .ToJson() method and written + to `.dsc.adaptedResource.json` files. These manifests enable DSCv3 to discover and + use PowerShell DSC resources without running Invoke-DscCacheRefresh. + + .PARAMETER Path + The path to a .ps1, .psm1, or .psd1 file containing class-based DSC resources. + When a .psd1 is provided, the RootModule is resolved and parsed automatically. + + .EXAMPLE + New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 + + Returns adapted resource manifest objects for all class-based DSC resources in the module. + + .EXAMPLE + New-DscAdaptedResourceManifest -Path ./MyResource.ps1 | ForEach-Object { + $_.ToJson() | Set-Content "$($_.Type -replace '/', '.').dsc.adaptedResource.json" + } + + Generates manifest objects and writes each to a JSON file. + + .EXAMPLE + Get-ChildItem -Path ./MyModules -Filter *.psd1 -Recurse | New-DscAdaptedResourceManifest + + Discovers all module manifests under `./MyModules` and pipes them into the function + to generate adapted resource manifests for every class-based DSC resource found. + + .OUTPUTS + Returns a DscAdaptedResourceManifest object for each class-based DSC resource found. + The object has a .ToJson() method for serialization to the adapted resource manifest + JSON format. +#> +function New-DscAdaptedResourceManifest { + [CmdletBinding()] + [OutputType([DscAdaptedResourceManifest])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateScript({ + if (-not (Test-Path -LiteralPath $_)) { + throw "Path '$_' does not exist." + } + $ext = [System.IO.Path]::GetExtension($_) + if ($ext -notin '.ps1', '.psm1', '.psd1') { + throw "Path '$_' must be a .ps1, .psm1, or .psd1 file." + } + return $true + })] + [string]$Path + ) + + process { + $moduleInfo = ResolveModuleInfo -Path $Path + + if (-not (Test-Path -LiteralPath $moduleInfo.ScriptPath)) { + Write-Error "Cannot find script file '$($moduleInfo.ScriptPath)' to parse." + return + } + + $dscTypes = GetDscResourceTypeDefinition -Path $moduleInfo.ScriptPath + + if ($dscTypes.Count -eq 0) { + Write-Warning "No class-based DSC resources found in '$Path'." + return + } + + $classHelpMap = GetClassCommentBasedHelp -Path $moduleInfo.ScriptPath + + foreach ($entry in $dscTypes) { + $typeDefinitionAst = $entry.TypeDefinitionAst + $allTypeDefinitions = $entry.AllTypeDefinitions + $resourceName = $typeDefinitionAst.Name + $resourceType = "$($moduleInfo.ModuleName)/$resourceName" + + Write-Verbose "Processing DSC resource '$resourceType'" + + $capabilities = GetDscResourceCapability -MemberAst $typeDefinitionAst.Members + $properties = GetDscResourceProperty -AllTypeDefinitions $allTypeDefinitions -TypeDefinitionAst $typeDefinitionAst + + $classHelp = $null + $resourceDescription = $moduleInfo.Description + + if ($classHelpMap.ContainsKey($resourceName)) { + $classHelp = $classHelpMap[$resourceName] + + if (-not [string]::IsNullOrWhiteSpace($classHelp.Synopsis)) { + $resourceDescription = $classHelp.Synopsis + } elseif (-not [string]::IsNullOrWhiteSpace($classHelp.Description)) { + $resourceDescription = $classHelp.Description + } + + $missingParams = @() + foreach ($prop in $properties) { + if (-not $classHelp.Parameters.ContainsKey($prop.Name)) { + $missingParams += $prop.Name + } + } + + if ($missingParams.Count -gt 0) { + Write-Warning "Class '$resourceName' comment-based help is missing .PARAMETER documentation for: $($missingParams -join ', ')" + } + } else { + Write-Warning "No comment-based help found above class '$resourceName'. Using default descriptions." + } + + $embeddedSchema = BuildEmbeddedJsonSchema -ResourceName $resourceType -Properties $properties -Description $resourceDescription -ClassHelp $classHelp + + $manifest = [DscAdaptedResourceManifest]::new() + $manifest.Schema = $script:AdaptedResourceSchemaUri + $manifest.Type = $resourceType + $manifest.Kind = 'resource' + $manifest.Version = $moduleInfo.Version + $manifest.Capabilities = @($capabilities) + $manifest.Description = $resourceDescription + $manifest.Author = $moduleInfo.Author + $manifest.RequireAdapter = $script:DefaultAdapter + $manifest.Path = $moduleInfo.Psd1Path + $manifest.ManifestSchema = [DscAdaptedResourceManifestSchema]@{ + Embedded = $embeddedSchema + } + + Write-Output $manifest + } + } +} + +<# + .SYNOPSIS + Creates a DSC resource manifests list for bundling multiple resources in a single file. + + .DESCRIPTION + Builds a DscResourceManifestList object that can contain both adapted resources and + command-based resources. The resulting object can be serialized to JSON and written + to a `.dsc.manifests.json` file, which DSCv3 discovers and loads as a bundle. + + Adapted resources can be added by piping DscAdaptedResourceManifest objects from + New-DscAdaptedResourceManifest. Command-based resources can be added via the + -Resource parameter as hashtables matching the DSCv3 resource manifest schema. + + .PARAMETER AdaptedResource + One or more DscAdaptedResourceManifest objects to include in the manifests list. + These are typically produced by New-DscAdaptedResourceManifest. + + .PARAMETER Resource + One or more hashtables representing command-based DSC resource manifests. Each + hashtable should conform to the DSCv3 resource manifest schema with keys such as + `$schema`, `type`, `version`, `get`, `set`, `test`, `schema`, etc. + + .EXAMPLE + $adapted = New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 + New-DscResourceManifest -AdaptedResource $adapted + + Creates a manifests list from adapted resource manifests generated from a module. + + .EXAMPLE + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + set = @{ executable = 'mytool'; args = @('set'); implementsPretest = $false; return = 'state' } + test = @{ executable = 'mytool'; args = @('test'); return = 'state' } + exitCodes = @{ '0' = 'Success'; '1' = 'Error' } + schema = @{ command = @{ executable = 'mytool'; args = @('schema') } } + } + New-DscResourceManifest -Resource $resource + + Creates a manifests list containing a single command-based resource. + + .EXAMPLE + $adapted = New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + } + New-DscResourceManifest -AdaptedResource $adapted -Resource $resource + + Creates a manifests list combining both adapted and command-based resources. + + .EXAMPLE + New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 | + New-DscResourceManifest + + Pipes adapted resource manifests directly into the function via the pipeline. + + .OUTPUTS + Returns a DscResourceManifestList object with a .ToJson() method for serialization + to the `.dsc.manifests.json` format. +#> +function New-DscResourceManifest { + [CmdletBinding()] + [OutputType([DscResourceManifestList])] + param( + [Parameter(ValueFromPipeline)] + [DscAdaptedResourceManifest[]]$AdaptedResource, + + [Parameter()] + [hashtable[]]$Resource + ) + + begin { + $manifestList = [DscResourceManifestList]::new() + + if ($Resource) { + foreach ($res in $Resource) { + $manifestList.AddResource($res) + } + } + } + + process { + if ($AdaptedResource) { + foreach ($adapted in $AdaptedResource) { + $manifestList.AddAdaptedResource($adapted) + } + } + } + + end { + Write-Output $manifestList + } +} + +<# + .SYNOPSIS + Imports adapted resource manifest objects from `.dsc.adaptedResource.json` files. + + .DESCRIPTION + Reads one or more `.dsc.adaptedResource.json` files and returns DscAdaptedResourceManifest + objects. This is the inverse of serializing a manifest with `.ToJson()` — it allows you + to load existing adapted resource manifests for inspection, modification, or inclusion + in a resource manifest list via New-DscResourceManifest. + + .PARAMETER Path + The path to a `.dsc.adaptedResource.json` file. Accepts pipeline input. + + .EXAMPLE + Import-DscAdaptedResourceManifest -Path ./MyResource.dsc.adaptedResource.json + + Imports a single adapted resource manifest and returns a DscAdaptedResourceManifest object. + + .EXAMPLE + Get-ChildItem -Filter *.dsc.adaptedResource.json | Import-DscAdaptedResourceManifest + + Imports all adapted resource manifest files in the current directory. + + .EXAMPLE + Import-DscAdaptedResourceManifest -Path ./MyResource.dsc.adaptedResource.json | + New-DscResourceManifest + + Imports an adapted resource manifest and bundles it into a resource manifest list. + + .OUTPUTS + Returns a DscAdaptedResourceManifest object for each file. The object has .ToJson() + and .ToHashtable() methods for serialization. +#> +function Import-DscAdaptedResourceManifest { + [CmdletBinding()] + [OutputType([DscAdaptedResourceManifest])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateScript({ + if (-not (Test-Path -LiteralPath $_)) { + throw "Path '$_' does not exist." + } + return $true + })] + [Alias('FullName')] + [string]$Path + ) + + process { + $resolvedPath = Resolve-Path -LiteralPath $Path + Write-Verbose "Importing adapted resource manifest from '$resolvedPath'" + + $jsonContent = Get-Content -LiteralPath $resolvedPath -Raw + $parsed = ConvertFrom-Json -InputObject $jsonContent + $hashtable = ConvertPSObjectToHashtable -InputObject $parsed + + $manifest = ConvertToAdaptedResourceManifest -Hashtable $hashtable + Write-Output $manifest + } +} + +<# + .SYNOPSIS + Imports a DSC resource manifest list from a `.dsc.manifests.json` file. + + .DESCRIPTION + Reads a `.dsc.manifests.json` file and returns a DscResourceManifestList object + containing the adapted resources, command-based resources, and extensions defined + in the file. This is the inverse of serializing a manifest list with `.ToJson()`. + + The adapted resources in the returned list are hydrated into DscAdaptedResourceManifest + objects and stored via AddAdaptedResource. Resources and extensions are stored as + hashtables. + + .PARAMETER Path + The path to a `.dsc.manifests.json` file. Accepts pipeline input. + + .EXAMPLE + Import-DscResourceManifest -Path ./MyModule.dsc.manifests.json + + Imports a manifest list file and returns a DscResourceManifestList object. + + .EXAMPLE + Get-ChildItem -Filter *.dsc.manifests.json | Import-DscResourceManifest + + Imports all manifest list files in the current directory. + + .EXAMPLE + $list = Import-DscResourceManifest -Path ./existing.dsc.manifests.json + $list.AdaptedResources.Count + + Imports a manifest list and inspects the number of adapted resources. + + .OUTPUTS + Returns a DscResourceManifestList object with .ToJson() for serialization. +#> +function Import-DscResourceManifest { + [CmdletBinding()] + [OutputType([DscResourceManifestList])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateScript({ + if (-not (Test-Path -LiteralPath $_)) { + throw "Path '$_' does not exist." + } + return $true + })] + [Alias('FullName')] + [string]$Path + ) + + process { + $resolvedPath = Resolve-Path -LiteralPath $Path + Write-Verbose "Importing resource manifest list from '$resolvedPath'" + + $jsonContent = Get-Content -LiteralPath $resolvedPath -Raw + $parsed = ConvertFrom-Json -InputObject $jsonContent + $hashtable = ConvertPSObjectToHashtable -InputObject $parsed + + $manifestList = [DscResourceManifestList]::new() + + if ($hashtable.Contains('adaptedResources')) { + foreach ($ar in $hashtable['adaptedResources']) { + $manifest = ConvertToAdaptedResourceManifest -Hashtable $ar + $manifestList.AddAdaptedResource($manifest) + } + } + + if ($hashtable.Contains('resources')) { + foreach ($res in $hashtable['resources']) { + $manifestList.AddResource($res) + } + } + + if ($hashtable.Contains('extensions')) { + foreach ($ext in $hashtable['extensions']) { + $manifestList.AddExtension($ext) + } + } + + Write-Output $manifestList + } +} + +<# + .SYNOPSIS + Creates a DscPropertyOverride object for use with Update-DscAdaptedResourceManifest. + + .DESCRIPTION + Constructs a DscPropertyOverride object that specifies how to modify a single property + in the embedded JSON schema of an adapted resource manifest. + + .PARAMETER Name + The name of the property in the embedded JSON schema to override. + + .PARAMETER Description + Override the property description text. + + .PARAMETER Title + Override the property title text. + + .PARAMETER JsonSchema + A hashtable of JSON schema keywords to merge into the property definition + (e.g., anyOf, oneOf, default, minimum, maximum, pattern, format). + + .PARAMETER RemoveKeys + An array of JSON schema key names to remove from the property before merging + JsonSchema (e.g., 'type', 'enum' when replacing with anyOf). + + .PARAMETER Required + Set to $true to add the property to the required list, $false to remove it, + or omit to leave unchanged. + + .EXAMPLE + New-DscPropertyOverride -Name 'Enabled' -Description 'Whether this resource is active.' + + Creates an override that sets a custom description for the Enabled property. + + .EXAMPLE + New-DscPropertyOverride -Name 'Status' -RemoveKeys 'type','enum' -JsonSchema @{ + anyOf = @( + @{ type = 'string'; enum = @('Active', 'Inactive') } + @{ type = 'integer'; minimum = 0 } + ) + } + + Creates an override that replaces the type/enum with an anyOf schema. + + .EXAMPLE + $overrides = @( + New-DscPropertyOverride -Name 'Name' -Description 'The unique identifier.' + New-DscPropertyOverride -Name 'Count' -JsonSchema @{ minimum = 0; maximum = 100 } + ) + $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $overrides + + Creates multiple overrides and pipes them to Update-DscAdaptedResourceManifest. + + .OUTPUTS + Returns a DscPropertyOverride object. +#> +function New-DscPropertyOverride { + [CmdletBinding()] + [OutputType([DscPropertyOverride])] + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter()] + [string]$Description, + + [Parameter()] + [string]$Title, + + [Parameter()] + [hashtable]$JsonSchema, + + [Parameter()] + [string[]]$RemoveKeys, + + [Parameter()] + [nullable[bool]]$Required + ) + + $override = [DscPropertyOverride]::new() + $override.Name = $Name + + if ($PSBoundParameters.ContainsKey('Description')) { + $override.Description = $Description + } + + if ($PSBoundParameters.ContainsKey('Title')) { + $override.Title = $Title + } + + if ($PSBoundParameters.ContainsKey('JsonSchema')) { + $override.JsonSchema = $JsonSchema + } + + if ($PSBoundParameters.ContainsKey('RemoveKeys')) { + $override.RemoveKeys = $RemoveKeys + } + + if ($PSBoundParameters.ContainsKey('Required')) { + $override.Required = $Required + } + + Write-Output $override +} + +<# + .SYNOPSIS + Applies post-processing overrides to adapted resource manifest objects. + + .DESCRIPTION + Modifies the embedded JSON schema of a DscAdaptedResourceManifest object by applying + property-level overrides. This enables customization that AST extraction alone cannot + provide, such as meaningful property descriptions, JSON schema keywords like anyOf or + oneOf for complex type unions, default values, numeric ranges, and string patterns. + + Property overrides are specified via DscPropertyOverride objects that target individual + properties by name. Each override can change the description, title, required status, + remove existing JSON schema keys, and merge in new JSON schema keywords. + + .PARAMETER InputObject + A DscAdaptedResourceManifest object to update. Typically produced by + New-DscAdaptedResourceManifest. Accepts pipeline input. + + .PARAMETER PropertyOverride + One or more DscPropertyOverride objects specifying modifications to individual + properties in the embedded JSON schema. Each override targets a property by Name. + + DscPropertyOverride supports the following fields: + - Name: (Required) The property name to modify. + - Description: Override the property description. + - Title: Override the property title. + - JsonSchema: A hashtable of JSON schema keywords to merge into the property + (e.g., anyOf, oneOf, default, minimum, maximum, pattern, format). + - RemoveKeys: An array of JSON schema key names to remove before merging + (e.g., 'type', 'enum' when replacing with anyOf). + - Required: Set to $true to mark as required, $false to remove from required, + or leave $null to keep unchanged. + + .PARAMETER Description + Override the resource-level description on both the manifest object and the embedded + JSON schema. + + .EXAMPLE + New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 | + Update-DscAdaptedResourceManifest -PropertyOverride @( + [DscPropertyOverride]@{ + Name = 'Name' + Description = 'The unique name identifying this resource instance.' + } + ) + + Overrides the auto-generated description for the Name property. + + .EXAMPLE + $overrides = @( + [DscPropertyOverride]@{ + Name = 'Status' + Description = 'The desired status, as a label or numeric code.' + RemoveKeys = @('type', 'enum') + JsonSchema = @{ + anyOf = @( + @{ type = 'string'; enum = @('Active', 'Inactive') } + @{ type = 'integer'; minimum = 0 } + ) + } + } + ) + New-DscAdaptedResourceManifest -Path ./MyModule.psd1 | + Update-DscAdaptedResourceManifest -PropertyOverride $overrides + + Replaces a simple enum property with an anyOf schema allowing either a string + enum or an integer value. + + .EXAMPLE + $override = [DscPropertyOverride]@{ + Name = 'Count' + JsonSchema = @{ minimum = 0; maximum = 100; default = 1 } + } + $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $override + + Adds numeric constraints and a default value to an existing integer property. + + .EXAMPLE + $override = [DscPropertyOverride]@{ + Name = 'Tags' + Required = $false + } + $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $override + + Removes a property from the required list. + + .OUTPUTS + Returns the modified DscAdaptedResourceManifest object. +#> +function Update-DscAdaptedResourceManifest { + [CmdletBinding()] + [OutputType([DscAdaptedResourceManifest])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [DscAdaptedResourceManifest]$InputObject, + + [Parameter()] + [DscPropertyOverride[]]$PropertyOverride, + + [Parameter()] + [string]$Description + ) + + process { + $schema = $InputObject.ManifestSchema.Embedded + + if (-not [string]::IsNullOrEmpty($Description)) { + $InputObject.Description = $Description + if ($schema.Contains('description')) { + $schema['description'] = $Description + } + } + + if ($PropertyOverride) { + $properties = $schema['properties'] + $requiredList = [System.Collections.Generic.List[string]]::new() + if ($schema.Contains('required') -and $null -ne $schema['required']) { + foreach ($r in $schema['required']) { + $requiredList.Add($r) + } + } + + foreach ($override in $PropertyOverride) { + if (-not $properties.Contains($override.Name)) { + Write-Warning "Property '$($override.Name)' not found in schema for '$($InputObject.Type)'. Skipping." + continue + } + + $prop = $properties[$override.Name] + + # Remove specified keys first + if ($override.RemoveKeys) { + foreach ($key in $override.RemoveKeys) { + if ($prop.Contains($key)) { + $prop.Remove($key) + } + } + } + + # Apply description override + if (-not [string]::IsNullOrEmpty($override.Description)) { + $prop['description'] = $override.Description + } + + # Apply title override + if (-not [string]::IsNullOrEmpty($override.Title)) { + $prop['title'] = $override.Title + } + + # Merge JSON schema keywords + if ($override.JsonSchema -and $override.JsonSchema.Count -gt 0) { + foreach ($key in $override.JsonSchema.Keys) { + $prop[$key] = $override.JsonSchema[$key] + } + } + + # Handle required override + if ($null -ne $override.Required) { + if ([bool]$override.Required -and $override.Name -notin $requiredList) { + $requiredList.Add($override.Name) + } elseif (-not [bool]$override.Required) { + $requiredList.Remove($override.Name) | Out-Null + } + } + } + + $schema['required'] = @($requiredList) + } + + Write-Output $InputObject + } +} + +#endregion Public functions diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/BothHelpResource/BothHelpResource.psd1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/BothHelpResource/BothHelpResource.psd1 new file mode 100644 index 000000000..cfa20c25d --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/BothHelpResource/BothHelpResource.psd1 @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + RootModule = 'BothHelpResource.psm1' + ModuleVersion = '1.0.0' + GUID = 'f6a7b8c9-d0e1-2345-f012-567890123def' + Author = 'Microsoft' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) Microsoft. All rights reserved.' + Description = 'Module with two classes, both with help.' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('FirstResource', 'SecondResource') +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/BothHelpResource/BothHelpResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/BothHelpResource/BothHelpResource.psm1 new file mode 100644 index 000000000..c88bbb877 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/BothHelpResource/BothHelpResource.psm1 @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + .SYNOPSIS + Manages the first resource. + + .PARAMETER Name + The unique name of the first resource. + + .PARAMETER Mode + The operating mode for the first resource. +#> +[DscResource()] +class FirstResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty()] + [string] $Mode + + [FirstResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} + +<# + .SYNOPSIS + Manages the second resource. + + .PARAMETER Id + The identifier for the second resource. + + .PARAMETER Label + A label for the second resource. +#> +[DscResource()] +class SecondResource { + [DscProperty(Key)] + [string] $Id + + [DscProperty()] + [string] $Label + + [SecondResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/HelpResource/HelpResource.psd1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/HelpResource/HelpResource.psd1 new file mode 100644 index 000000000..919536ed5 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/HelpResource/HelpResource.psd1 @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + RootModule = 'HelpResource.psm1' + ModuleVersion = '1.0.0' + GUID = 'c3d4e5f6-a7b8-9012-cdef-234567890abc' + Author = 'Microsoft' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) Microsoft. All rights reserved.' + Description = 'Module with comment-based help on DSC resources.' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('HelpResource') +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/HelpResource/HelpResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/HelpResource/HelpResource.psm1 new file mode 100644 index 000000000..fe4c2ca45 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/HelpResource/HelpResource.psm1 @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + .SYNOPSIS + Manages a help-documented resource. + + .DESCRIPTION + The HelpResource DSC resource is used to manage a help-documented resource + with full comment-based help including synopsis, description, and all + parameter documentation. + + .PARAMETER Name + The unique name identifying this resource instance. + + .PARAMETER Value + The value to assign to this resource. + + .PARAMETER Enabled + Whether this resource is active. +#> +[DscResource()] +class HelpResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty(Mandatory)] + [string] $Value + + [DscProperty()] + [bool] $Enabled + + [HelpResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MinimalResource.dsc.adaptedResource.json b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MinimalResource.dsc.adaptedResource.json new file mode 100644 index 000000000..01a0d71c7 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MinimalResource.dsc.adaptedResource.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json", + "type": "TestModule/MinimalResource", + "version": "0.1.0", + "requireAdapter": "Microsoft.Adapter/PowerShell", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestModule/MinimalResource", + "type": "object", + "required": [], + "additionalProperties": false, + "properties": { + "Id": { + "type": "integer", + "title": "Id", + "description": "The Id property." + } + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MixedHelpResource/MixedHelpResource.psd1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MixedHelpResource/MixedHelpResource.psd1 new file mode 100644 index 000000000..63fffe65e --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MixedHelpResource/MixedHelpResource.psd1 @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + RootModule = 'MixedHelpResource.psm1' + ModuleVersion = '1.0.0' + GUID = 'e5f6a7b8-c9d0-1234-ef01-456789012cde' + Author = 'Microsoft' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) Microsoft. All rights reserved.' + Description = 'Module with two classes, one with help and one without.' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('DocumentedResource', 'UndocumentedResource') +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MixedHelpResource/MixedHelpResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MixedHelpResource/MixedHelpResource.psm1 new file mode 100644 index 000000000..c4d395a8a --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MixedHelpResource/MixedHelpResource.psm1 @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + .SYNOPSIS + A fully documented DSC resource. + + .DESCRIPTION + The DocumentedResource DSC resource has complete comment-based help + covering all parameters. + + .PARAMETER Name + The unique identifier for the resource. + + .PARAMETER Setting + The configuration setting to apply. +#> +[DscResource()] +class DocumentedResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty()] + [string] $Setting + + [DocumentedResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} + +[DscResource()] +class UndocumentedResource { + [DscProperty(Key)] + [string] $Id + + [DscProperty()] + [string] $Data + + [UndocumentedResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psd1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psd1 new file mode 100644 index 000000000..f5502df1a --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psd1 @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + RootModule = 'MultiResource.psm1' + ModuleVersion = '2.5.0' + GUID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901' + Author = 'Microsoft' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) Microsoft. All rights reserved.' + Description = 'Module with multiple DSC resources.' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('ResourceA', 'ResourceB') +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psm1 new file mode 100644 index 000000000..5d3645579 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/MultiResource/MultiResource.psm1 @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +enum Ensure { + Present + Absent +} + +class BaseResource { + [DscProperty()] + [string] $BaseProperty +} + +[DscResource()] +class ResourceA : BaseResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty()] + [Ensure] $Ensure + + [DscProperty()] + [int] $Count + + [DscProperty()] + [string[]] $Tags + + [ResourceA] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } + + [void] Delete() { + } + + static [ResourceA[]] Export() { + return @() + } +} + +[DscResource()] +class ResourceB { + [DscProperty(Key)] + [string] $Id + + [DscProperty()] + [hashtable] $Settings + + [ResourceB] Get() { + return $this + } + + [bool] Test() { + return $false + } + + [void] Set() { + } + + [bool] WhatIf() { + return $true + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/NoDscResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/NoDscResource.psm1 new file mode 100644 index 000000000..4c74ec2b2 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/NoDscResource.psm1 @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# A helper module with no DSC resources +function Get-SomeValue { + return 'hello' +} + +class NotADscResource { + [string] $Name + + [string] GetName() { + return $this.Name + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/PartialHelpResource/PartialHelpResource.psd1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/PartialHelpResource/PartialHelpResource.psd1 new file mode 100644 index 000000000..15aca8908 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/PartialHelpResource/PartialHelpResource.psd1 @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + RootModule = 'PartialHelpResource.psm1' + ModuleVersion = '1.0.0' + GUID = 'd4e5f6a7-b8c9-0123-def0-345678901bcd' + Author = 'Microsoft' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) Microsoft. All rights reserved.' + Description = 'Module with partial comment-based help.' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('PartialHelpResource') +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/PartialHelpResource/PartialHelpResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/PartialHelpResource/PartialHelpResource.psm1 new file mode 100644 index 000000000..6fa4503d7 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/PartialHelpResource/PartialHelpResource.psm1 @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + .SYNOPSIS + Manages a partially documented resource. + + .PARAMETER Name + The unique name for the resource. +#> +[DscResource()] +class PartialHelpResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty()] + [string] $Value + + [DscProperty()] + [int] $Count + + [PartialHelpResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource.dsc.adaptedResource.json b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource.dsc.adaptedResource.json new file mode 100644 index 000000000..f58feb716 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource.dsc.adaptedResource.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json", + "type": "SimpleResource/SimpleResource", + "kind": "resource", + "version": "1.0.0", + "capabilities": [ + "get", + "set", + "test" + ], + "description": "A simple DSC resource for testing.", + "author": "Microsoft", + "requireAdapter": "Microsoft.Adapter/PowerShell", + "path": "SimpleResource/SimpleResource.psd1", + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SimpleResource/SimpleResource", + "type": "object", + "required": [ + "Name" + ], + "additionalProperties": false, + "properties": { + "Name": { + "type": "string", + "title": "Name", + "description": "The Name property." + }, + "Value": { + "type": "string", + "title": "Value", + "description": "The Value property." + } + }, + "description": "A simple DSC resource for testing." + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psd1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psd1 new file mode 100644 index 000000000..2c6bc4824 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psd1 @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + RootModule = 'SimpleResource.psm1' + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + Author = 'Microsoft' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) Microsoft. All rights reserved.' + Description = 'A simple DSC resource for testing.' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('SimpleResource') +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psm1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psm1 new file mode 100644 index 000000000..f4a71ae9b --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/SimpleResource/SimpleResource.psm1 @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[DscResource()] +class SimpleResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty(Mandatory)] + [string] $Value + + [DscProperty()] + [bool] $Enabled + + [SimpleResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/StandaloneResource.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/StandaloneResource.ps1 new file mode 100644 index 000000000..ae2f3420d --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/StandaloneResource.ps1 @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[DscResource()] +class StandaloneResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty()] + [string] $Content + + [StandaloneResource] Get() { + return $this + } + + [bool] Test() { + return $true + } + + [void] Set() { + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/TestModule.dsc.manifests.json b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/TestModule.dsc.manifests.json new file mode 100644 index 000000000..9c92eb795 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Fixtures/TestModule.dsc.manifests.json @@ -0,0 +1,102 @@ +{ + "adaptedResources": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json", + "type": "TestModule/ResourceOne", + "kind": "resource", + "version": "1.0.0", + "capabilities": [ + "get", + "set" + ], + "description": "First test resource.", + "author": "TestAuthor", + "requireAdapter": "Microsoft.Adapter/PowerShell", + "path": "TestModule/TestModule.psd1", + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestModule/ResourceOne", + "type": "object", + "required": [ + "Name" + ], + "additionalProperties": false, + "properties": { + "Name": { + "type": "string", + "title": "Name", + "description": "The Name property." + } + }, + "description": "First test resource." + } + } + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json", + "type": "TestModule/ResourceTwo", + "kind": "resource", + "version": "1.0.0", + "capabilities": [ + "get" + ], + "description": "Second test resource.", + "author": "TestAuthor", + "requireAdapter": "Microsoft.Adapter/PowerShell", + "path": "TestModule/TestModule.psd1", + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestModule/ResourceTwo", + "type": "object", + "required": [], + "additionalProperties": false, + "properties": { + "Value": { + "type": "integer", + "title": "Value", + "description": "The Value property." + } + }, + "description": "Second test resource." + } + } + } + ], + "resources": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/CommandResource", + "version": "0.1.0", + "get": { + "executable": "testcmd", + "args": [ + "get" + ] + }, + "schema": { + "command": { + "executable": "testcmd", + "args": [ + "schema" + ] + } + } + } + ], + "extensions": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/Extension", + "version": "0.1.0", + "description": "A test extension.", + "discover": { + "executable": "testcmd", + "args": [ + "discover" + ] + } + } + ] +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/GetClassCommentBasedHelp.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/GetClassCommentBasedHelp.Tests.ps1 new file mode 100644 index 000000000..8e628fa05 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/GetClassCommentBasedHelp.Tests.ps1 @@ -0,0 +1,263 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'GetClassCommentBasedHelp integration' { + + BeforeAll { + $modulePath = Join-Path (Join-Path $PSScriptRoot '..') 'Microsoft.PowerShell.DSC.psd1' + Import-Module $modulePath -Force + + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'Single class with full comment-based help' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'HelpResource' 'HelpResource.psd1' + $result = New-DscAdaptedResourceManifest -Path $psd1 -WarningVariable warnings -WarningAction SilentlyContinue + } + + It 'Returns exactly one manifest object' { + $result | Should -HaveCount 1 + } + + It 'Uses Synopsis as the manifest description' { + $result.Description | Should -BeExactly 'Manages a help-documented resource.' + } + + It 'Uses Synopsis as the schema description' { + $result.ManifestSchema.Embedded['description'] | Should -BeExactly 'Manages a help-documented resource.' + } + + It 'Sets the Name property description from .PARAMETER help' { + $result.ManifestSchema.Embedded['properties']['Name']['description'] | + Should -BeExactly 'The unique name identifying this resource instance.' + } + + It 'Sets the Value property description from .PARAMETER help' { + $result.ManifestSchema.Embedded['properties']['Value']['description'] | + Should -BeExactly 'The value to assign to this resource.' + } + + It 'Sets the Enabled property description from .PARAMETER help' { + $result.ManifestSchema.Embedded['properties']['Enabled']['description'] | + Should -BeExactly 'Whether this resource is active.' + } + + It 'Does not emit any warnings about missing parameter documentation' { + $warnings | Should -BeNullOrEmpty + } + } + + Context 'Single class with partial comment-based help (missing some .PARAMETER entries)' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'PartialHelpResource' 'PartialHelpResource.psd1' + $result = New-DscAdaptedResourceManifest -Path $psd1 -WarningVariable warnings -WarningAction SilentlyContinue + } + + It 'Returns exactly one manifest object' { + $result | Should -HaveCount 1 + } + + It 'Uses Synopsis as the manifest description' { + $result.Description | Should -BeExactly 'Manages a partially documented resource.' + } + + It 'Sets the Name property description from .PARAMETER help' { + $result.ManifestSchema.Embedded['properties']['Name']['description'] | + Should -BeExactly 'The unique name for the resource.' + } + + It 'Falls back to default description for undocumented Value property' { + $result.ManifestSchema.Embedded['properties']['Value']['description'] | + Should -BeExactly 'The Value property.' + } + + It 'Falls back to default description for undocumented Count property' { + $result.ManifestSchema.Embedded['properties']['Count']['description'] | + Should -BeExactly 'The Count property.' + } + + It 'Emits a warning about missing parameter documentation' { + $warnings | Should -Not -BeNullOrEmpty + $warnings[0] | Should -BeLike "*missing .PARAMETER documentation for: Value, Count" + } + } + + Context 'File with no comment-based help on class' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $result = New-DscAdaptedResourceManifest -Path $psd1 -WarningVariable warnings -WarningAction SilentlyContinue + } + + It 'Returns a manifest object' { + $result | Should -HaveCount 1 + } + + It 'Falls back to module description' { + $result.Description | Should -BeExactly 'A simple DSC resource for testing.' + } + + It 'Uses default property descriptions' { + $result.ManifestSchema.Embedded['properties']['Name']['description'] | + Should -BeExactly 'The Name property.' + } + + It 'Emits a warning about no comment-based help found' { + $warnings | Should -Not -BeNullOrEmpty + $warnings[0] | Should -BeLike "*No comment-based help found above class*" + } + } + + Context 'Two classes in one file - one with help, one without' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'MixedHelpResource' 'MixedHelpResource.psd1' + $results = @(New-DscAdaptedResourceManifest -Path $psd1 -WarningVariable warnings -WarningAction SilentlyContinue) + } + + It 'Returns two manifest objects' { + $results | Should -HaveCount 2 + } + + Context 'DocumentedResource - has comment-based help' { + + BeforeAll { + $documented = $results | Where-Object { $_.Type -eq 'MixedHelpResource/DocumentedResource' } + } + + It 'Uses Synopsis as the manifest description' { + $documented.Description | Should -BeExactly 'A fully documented DSC resource.' + } + + It 'Sets the Name property description from .PARAMETER help' { + $documented.ManifestSchema.Embedded['properties']['Name']['description'] | + Should -BeExactly 'The unique identifier for the resource.' + } + + It 'Sets the Setting property description from .PARAMETER help' { + $documented.ManifestSchema.Embedded['properties']['Setting']['description'] | + Should -BeExactly 'The configuration setting to apply.' + } + } + + Context 'UndocumentedResource - no comment-based help' { + + BeforeAll { + $undocumented = $results | Where-Object { $_.Type -eq 'MixedHelpResource/UndocumentedResource' } + } + + It 'Falls back to module description' { + $undocumented.Description | Should -BeExactly 'Module with two classes, one with help and one without.' + } + + It 'Uses default property description for Id' { + $undocumented.ManifestSchema.Embedded['properties']['Id']['description'] | + Should -BeExactly 'The Id property.' + } + + It 'Uses default property description for Data' { + $undocumented.ManifestSchema.Embedded['properties']['Data']['description'] | + Should -BeExactly 'The Data property.' + } + } + + It 'Emits a warning for UndocumentedResource but not DocumentedResource' { + $noHelpWarning = $warnings | Where-Object { $_ -like "*No comment-based help found above class 'UndocumentedResource'*" } + $noHelpWarning | Should -Not -BeNullOrEmpty + + $documentedWarning = $warnings | Where-Object { $_ -like "*No comment-based help found above class 'DocumentedResource'*" } + $documentedWarning | Should -BeNullOrEmpty + } + } + + Context 'Two classes in one file - both with comment-based help' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'BothHelpResource' 'BothHelpResource.psd1' + $results = @(New-DscAdaptedResourceManifest -Path $psd1 -WarningVariable warnings -WarningAction SilentlyContinue) + } + + It 'Returns two manifest objects' { + $results | Should -HaveCount 2 + } + + Context 'FirstResource' { + + BeforeAll { + $first = $results | Where-Object { $_.Type -eq 'BothHelpResource/FirstResource' } + } + + It 'Uses Synopsis as the manifest description' { + $first.Description | Should -BeExactly 'Manages the first resource.' + } + + It 'Sets Name property description from help' { + $first.ManifestSchema.Embedded['properties']['Name']['description'] | + Should -BeExactly 'The unique name of the first resource.' + } + + It 'Sets Mode property description from help' { + $first.ManifestSchema.Embedded['properties']['Mode']['description'] | + Should -BeExactly 'The operating mode for the first resource.' + } + } + + Context 'SecondResource' { + + BeforeAll { + $second = $results | Where-Object { $_.Type -eq 'BothHelpResource/SecondResource' } + } + + It 'Uses Synopsis as the manifest description' { + $second.Description | Should -BeExactly 'Manages the second resource.' + } + + It 'Sets Id property description from help' { + $second.ManifestSchema.Embedded['properties']['Id']['description'] | + Should -BeExactly 'The identifier for the second resource.' + } + + It 'Sets Label property description from help' { + $second.ManifestSchema.Embedded['properties']['Label']['description'] | + Should -BeExactly 'A label for the second resource.' + } + } + + It 'Does not emit warnings about missing comment-based help' { + $noHelpWarnings = $warnings | Where-Object { $_ -like "*No comment-based help found*" } + $noHelpWarnings | Should -BeNullOrEmpty + } + + It 'Does not emit warnings about missing parameter documentation' { + $paramWarnings = $warnings | Where-Object { $_ -like "*missing .PARAMETER*" } + $paramWarnings | Should -BeNullOrEmpty + } + } + + Context 'Existing tests still pass - SimpleResource without help retains correct schema' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $result = New-DscAdaptedResourceManifest -Path $psd1 -WarningAction SilentlyContinue + } + + It 'Schema still has correct $schema URI' { + $result.ManifestSchema.Embedded['$schema'] | Should -BeExactly 'https://json-schema.org/draft/2020-12/schema' + } + + It 'Schema still marks Key property Name as required' { + $result.ManifestSchema.Embedded['required'] | Should -Contain 'Name' + } + + It 'Schema still maps string properties correctly' { + $result.ManifestSchema.Embedded['properties']['Name']['type'] | Should -BeExactly 'string' + } + + It 'Schema still maps bool properties correctly' { + $result.ManifestSchema.Embedded['properties']['Enabled']['type'] | Should -BeExactly 'boolean' + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Import-DscAdaptedResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/Import-DscAdaptedResourceManifest.Tests.ps1 new file mode 100644 index 000000000..fad21789c --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Import-DscAdaptedResourceManifest.Tests.ps1 @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Import-DscAdaptedResourceManifest' { + + BeforeAll { + $modulePath = Join-Path (Join-Path $PSScriptRoot '..') 'Microsoft.PowerShell.DSC.psd1' + Import-Module $modulePath -Force + + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'Importing a full adapted resource manifest' { + + BeforeAll { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $result = Import-DscAdaptedResourceManifest -Path $jsonPath + } + + It 'Returns a DscAdaptedResourceManifest object' { + $result.GetType().Name | Should -BeExactly 'DscAdaptedResourceManifest' + } + + It 'Imports the schema URI' { + $result.Schema | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + } + + It 'Imports the type' { + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Imports the kind' { + $result.Kind | Should -BeExactly 'resource' + } + + It 'Imports the version' { + $result.Version | Should -BeExactly '1.0.0' + } + + It 'Imports capabilities as an array' { + $result.Capabilities | Should -HaveCount 3 + $result.Capabilities | Should -Contain 'get' + $result.Capabilities | Should -Contain 'set' + $result.Capabilities | Should -Contain 'test' + } + + It 'Imports the description' { + $result.Description | Should -BeExactly 'A simple DSC resource for testing.' + } + + It 'Imports the author' { + $result.Author | Should -BeExactly 'Microsoft' + } + + It 'Imports the requireAdapter' { + $result.RequireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + } + + It 'Imports the path' { + $result.Path | Should -BeExactly 'SimpleResource/SimpleResource.psd1' + } + + It 'Imports the embedded schema' { + $result.ManifestSchema | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded | Should -Not -BeNullOrEmpty + } + + It 'Has correct schema properties' { + $result.ManifestSchema.Embedded['properties'] | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded['properties']['Name'] | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded['properties']['Value'] | Should -Not -BeNullOrEmpty + } + + It 'Has correct required fields in embedded schema' { + $result.ManifestSchema.Embedded['required'] | Should -Contain 'Name' + } + } + + Context 'Importing a minimal adapted resource manifest without optional fields' { + + BeforeAll { + $jsonPath = Join-Path $fixturesPath 'MinimalResource.dsc.adaptedResource.json' + $result = Import-DscAdaptedResourceManifest -Path $jsonPath + } + + It 'Returns a DscAdaptedResourceManifest object' { + $result.GetType().Name | Should -BeExactly 'DscAdaptedResourceManifest' + } + + It 'Imports the type' { + $result.Type | Should -BeExactly 'TestModule/MinimalResource' + } + + It 'Defaults kind to resource when missing' { + $result.Kind | Should -BeExactly 'resource' + } + + It 'Defaults capabilities to empty array when missing' { + $result.Capabilities.Count | Should -Be 0 + } + + It 'Defaults description to empty string when missing' { + $result.Description | Should -BeExactly '' + } + + It 'Defaults author to empty string when missing' { + $result.Author | Should -BeExactly '' + } + + It 'Defaults path to empty string when missing' { + $result.Path | Should -BeExactly '' + } + + It 'Handles schema without embedded wrapper' { + $result.ManifestSchema | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded['properties']['Id'] | Should -Not -BeNullOrEmpty + } + } + + Context 'Pipeline input' { + + It 'Accepts paths from the pipeline' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $result = $jsonPath | Import-DscAdaptedResourceManifest + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Accepts FileInfo objects from the pipeline' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $result = Get-Item $jsonPath | Import-DscAdaptedResourceManifest + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Processes multiple files from the pipeline' { + $files = @( + (Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json') + (Join-Path $fixturesPath 'MinimalResource.dsc.adaptedResource.json') + ) + $results = $files | Import-DscAdaptedResourceManifest + $results | Should -HaveCount 2 + $results[0].Type | Should -BeExactly 'SimpleResource/SimpleResource' + $results[1].Type | Should -BeExactly 'TestModule/MinimalResource' + } + } + + Context 'Round-trip fidelity' { + + It 'Produces identical JSON after import and re-export' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $original = Get-Content -LiteralPath $jsonPath -Raw | ConvertFrom-Json + $imported = Import-DscAdaptedResourceManifest -Path $jsonPath + $reExported = $imported.ToJson() | ConvertFrom-Json + + $reExported.type | Should -BeExactly $original.type + $reExported.kind | Should -BeExactly $original.kind + $reExported.version | Should -BeExactly $original.version + $reExported.requireAdapter | Should -BeExactly $original.requireAdapter + $reExported.path | Should -BeExactly $original.path + $reExported.author | Should -BeExactly $original.author + $reExported.description | Should -BeExactly $original.description + } + } + + Context 'ToHashtable round-trip' { + + It 'Converts imported manifest to hashtable correctly' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $imported = Import-DscAdaptedResourceManifest -Path $jsonPath + $ht = $imported.ToHashtable() + + $ht['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + $ht['version'] | Should -BeExactly '1.0.0' + $ht['requireAdapter'] | Should -BeExactly 'Microsoft.Adapter/PowerShell' + $ht['path'] | Should -BeExactly 'SimpleResource/SimpleResource.psd1' + $ht['schema']['embedded'] | Should -Not -BeNullOrEmpty + } + } + + Context 'Error handling' { + + It 'Throws when the path does not exist' { + { Import-DscAdaptedResourceManifest -Path 'nonexistent.json' } | Should -Throw '*does not exist*' + } + } + + Context 'Integration with New-DscResourceManifest' { + + It 'Imported manifests can be added to a resource manifest list' { + $jsonPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + $imported = Import-DscAdaptedResourceManifest -Path $jsonPath + + $list = $imported | New-DscResourceManifest + $list.AdaptedResources | Should -HaveCount 1 + $list.AdaptedResources[0]['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Import-DscResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/Import-DscResourceManifest.Tests.ps1 new file mode 100644 index 000000000..c247ac77d --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Import-DscResourceManifest.Tests.ps1 @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Import-DscResourceManifest' { + + BeforeAll { + $modulePath = Join-Path (Join-Path $PSScriptRoot '..') 'Microsoft.PowerShell.DSC.psd1' + Import-Module $modulePath -Force + + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'Importing a manifest list with all sections' { + + BeforeAll { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $result = Import-DscResourceManifest -Path $jsonPath + } + + It 'Returns a DscResourceManifestList object' { + $result.GetType().Name | Should -BeExactly 'DscResourceManifestList' + } + + It 'Imports two adapted resources' { + $result.AdaptedResources | Should -HaveCount 2 + } + + It 'Imports the first adapted resource type' { + $result.AdaptedResources[0]['type'] | Should -BeExactly 'TestModule/ResourceOne' + } + + It 'Imports the second adapted resource type' { + $result.AdaptedResources[1]['type'] | Should -BeExactly 'TestModule/ResourceTwo' + } + + It 'Imports adapted resource capabilities' { + $result.AdaptedResources[0]['capabilities'] | Should -Contain 'get' + $result.AdaptedResources[0]['capabilities'] | Should -Contain 'set' + } + + It 'Imports adapted resource version' { + $result.AdaptedResources[0]['version'] | Should -BeExactly '1.0.0' + } + + It 'Imports adapted resource requireAdapter' { + $result.AdaptedResources[0]['requireAdapter'] | Should -BeExactly 'Microsoft.Adapter/PowerShell' + } + + It 'Imports adapted resource schema with embedded key' { + $result.AdaptedResources[0]['schema'] | Should -Not -BeNullOrEmpty + $result.AdaptedResources[0]['schema']['embedded'] | Should -Not -BeNullOrEmpty + } + + It 'Imports one command-based resource' { + $result.Resources | Should -HaveCount 1 + } + + It 'Imports the resource type' { + $result.Resources[0]['type'] | Should -BeExactly 'Test/CommandResource' + } + + It 'Imports the resource version' { + $result.Resources[0]['version'] | Should -BeExactly '0.1.0' + } + + It 'Imports the resource get command' { + $result.Resources[0]['get'] | Should -Not -BeNullOrEmpty + $result.Resources[0]['get']['executable'] | Should -BeExactly 'testcmd' + } + + It 'Imports one extension' { + $result.Extensions | Should -HaveCount 1 + } + + It 'Imports the extension type' { + $result.Extensions[0]['type'] | Should -BeExactly 'Test/Extension' + } + + It 'Imports the extension discover command' { + $result.Extensions[0]['discover'] | Should -Not -BeNullOrEmpty + $result.Extensions[0]['discover']['executable'] | Should -BeExactly 'testcmd' + } + } + + Context 'Importing a manifest list with only adapted resources' { + + BeforeAll { + $json = @{ + adaptedResources = @( + @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + type = 'OnlyAdapted/Resource' + version = '2.0.0' + requireAdapter = 'Microsoft.Adapter/PowerShell' + schema = @{ + embedded = @{ + type = 'object' + properties = @{} + } + } + } + ) + } | ConvertTo-Json -Depth 10 + + $tempFile = Join-Path $TestDrive 'adapted-only.dsc.manifests.json' + $json | Set-Content -LiteralPath $tempFile -Encoding utf8 + $result = Import-DscResourceManifest -Path $tempFile + } + + It 'Imports the adapted resource' { + $result.AdaptedResources | Should -HaveCount 1 + $result.AdaptedResources[0]['type'] | Should -BeExactly 'OnlyAdapted/Resource' + } + + It 'Has empty resources list' { + $result.Resources | Should -HaveCount 0 + } + + It 'Has empty extensions list' { + $result.Extensions | Should -HaveCount 0 + } + } + + Context 'Importing a manifest list with only resources' { + + BeforeAll { + $json = @{ + resources = @( + @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'OnlyCommand/Resource' + version = '0.5.0' + get = @{ + executable = 'mycmd' + args = @('get') + } + } + ) + } | ConvertTo-Json -Depth 10 + + $tempFile = Join-Path $TestDrive 'resources-only.dsc.manifests.json' + $json | Set-Content -LiteralPath $tempFile -Encoding utf8 + $result = Import-DscResourceManifest -Path $tempFile + } + + It 'Has empty adapted resources list' { + $result.AdaptedResources | Should -HaveCount 0 + } + + It 'Imports the resource' { + $result.Resources | Should -HaveCount 1 + $result.Resources[0]['type'] | Should -BeExactly 'OnlyCommand/Resource' + } + + It 'Has empty extensions list' { + $result.Extensions | Should -HaveCount 0 + } + } + + Context 'Pipeline input' { + + It 'Accepts paths from the pipeline' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $result = $jsonPath | Import-DscResourceManifest + $result.AdaptedResources | Should -HaveCount 2 + } + + It 'Accepts FileInfo objects from the pipeline' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $result = Get-Item $jsonPath | Import-DscResourceManifest + $result.AdaptedResources | Should -HaveCount 2 + } + } + + Context 'Round-trip fidelity' { + + It 'Re-exports JSON that preserves adapted resource types' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $imported = Import-DscResourceManifest -Path $jsonPath + $reExported = $imported.ToJson() | ConvertFrom-Json + + $reExported.adaptedResources | Should -HaveCount 2 + $reExported.adaptedResources[0].type | Should -BeExactly 'TestModule/ResourceOne' + $reExported.adaptedResources[1].type | Should -BeExactly 'TestModule/ResourceTwo' + } + + It 'Re-exports JSON that preserves resource types' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $imported = Import-DscResourceManifest -Path $jsonPath + $reExported = $imported.ToJson() | ConvertFrom-Json + + $reExported.resources | Should -HaveCount 1 + $reExported.resources[0].type | Should -BeExactly 'Test/CommandResource' + } + + It 'Re-exports JSON that preserves extension types' { + $jsonPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $imported = Import-DscResourceManifest -Path $jsonPath + $reExported = $imported.ToJson() | ConvertFrom-Json + + $reExported.extensions | Should -HaveCount 1 + $reExported.extensions[0].type | Should -BeExactly 'Test/Extension' + } + } + + Context 'Error handling' { + + It 'Throws when the path does not exist' { + { Import-DscResourceManifest -Path 'nonexistent.json' } | Should -Throw '*does not exist*' + } + } + + Context 'Integration with Import-DscAdaptedResourceManifest' { + + It 'Imported adapted manifests can be added to an imported manifest list' { + $manifestPath = Join-Path $fixturesPath 'TestModule.dsc.manifests.json' + $adaptedPath = Join-Path $fixturesPath 'SimpleResource.dsc.adaptedResource.json' + + $list = Import-DscResourceManifest -Path $manifestPath + $adapted = Import-DscAdaptedResourceManifest -Path $adaptedPath + $list.AddAdaptedResource($adapted) + + $list.AdaptedResources | Should -HaveCount 3 + $list.AdaptedResources[2]['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 new file mode 100644 index 000000000..de347fcd9 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/New-DscAdaptedResourceManifest.Tests.ps1 @@ -0,0 +1,342 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'New-DscAdaptedResourceManifest' { + + BeforeAll { + $modulePath = Join-Path (Join-Path $PSScriptRoot '..') 'Microsoft.PowerShell.DSC.psd1' + Import-Module $modulePath -Force + + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'Simple module with a single DSC resource' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $result = New-DscAdaptedResourceManifest -Path $psd1 + } + + It 'Returns exactly one manifest object' { + $result | Should -HaveCount 1 + } + + It 'Returns a DscAdaptedResourceManifest object' { + $result.GetType().Name | Should -BeExactly 'DscAdaptedResourceManifest' + } + + It 'Sets the correct resource type' { + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Sets the kind to resource' { + $result.Kind | Should -BeExactly 'resource' + } + + It 'Sets the version from the module manifest' { + $result.Version | Should -BeExactly '1.0.0' + } + + It 'Sets the description from the module manifest' { + $result.Description | Should -BeExactly 'A simple DSC resource for testing.' + } + + It 'Sets the author from the module manifest' { + $result.Author | Should -BeExactly 'Microsoft' + } + + It 'Sets the schema URI' { + $result.Schema | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + } + + It 'Sets the require adapter to Microsoft.DSC/PowerShell' { + $result.RequireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + } + + It 'Sets the path to the psd1 relative path' { + $result.Path | Should -BeLike '*SimpleResource*' + } + + It 'Detects get, set, and test capabilities' { + $result.Capabilities | Should -Contain 'get' + $result.Capabilities | Should -Contain 'set' + $result.Capabilities | Should -Contain 'test' + } + + It 'Does not include capabilities for methods that do not exist' { + $result.Capabilities | Should -Not -Contain 'delete' + $result.Capabilities | Should -Not -Contain 'export' + $result.Capabilities | Should -Not -Contain 'whatIf' + } + + It 'Includes an embedded JSON schema' { + $result.ManifestSchema | Should -Not -BeNullOrEmpty + $result.ManifestSchema.Embedded | Should -Not -BeNullOrEmpty + } + + It 'Schema has correct $schema URI' { + $result.ManifestSchema.Embedded['$schema'] | Should -BeExactly 'https://json-schema.org/draft/2020-12/schema' + } + + It 'Schema has type set to object' { + $result.ManifestSchema.Embedded['type'] | Should -BeExactly 'object' + } + + It 'Schema includes Key property as required' { + $result.ManifestSchema.Embedded['required'] | Should -Contain 'Name' + } + + It 'Schema includes Mandatory property as required' { + $result.ManifestSchema.Embedded['required'] | Should -Contain 'Value' + } + + It 'Schema maps string properties correctly' { + $result.ManifestSchema.Embedded['properties']['Name']['type'] | Should -BeExactly 'string' + $result.ManifestSchema.Embedded['properties']['Value']['type'] | Should -BeExactly 'string' + } + + It 'Schema maps bool properties correctly' { + $result.ManifestSchema.Embedded['properties']['Enabled']['type'] | Should -BeExactly 'boolean' + } + } + + Context 'Module with multiple DSC resources, inheritance, and enums' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1' + $results = @(New-DscAdaptedResourceManifest -Path $psd1) + } + + It 'Returns two manifest objects' { + $results | Should -HaveCount 2 + } + + It 'Returns manifests for ResourceA and ResourceB' { + $results.Type | Should -Contain 'MultiResource/ResourceA' + $results.Type | Should -Contain 'MultiResource/ResourceB' + } + + It 'All manifests share the same module version' { + $results | ForEach-Object { + $_.Version | Should -BeExactly '2.5.0' + } + } + + It 'All manifests share the same author' { + $results | ForEach-Object { + $_.Author | Should -BeExactly 'Microsoft' + } + } + + Context 'ResourceA - inheritance, enums, delete, export' { + + BeforeAll { + $resourceA = $results | Where-Object { $_.Type -eq 'MultiResource/ResourceA' } + } + + It 'Detects get, set, test, delete, and export capabilities' { + $resourceA.Capabilities | Should -Contain 'get' + $resourceA.Capabilities | Should -Contain 'set' + $resourceA.Capabilities | Should -Contain 'test' + $resourceA.Capabilities | Should -Contain 'delete' + $resourceA.Capabilities | Should -Contain 'export' + } + + It 'Includes inherited BaseProperty from base class' { + $resourceA.ManifestSchema.Embedded['properties'].Keys | Should -Contain 'BaseProperty' + } + + It 'Includes own properties' { + $props = $resourceA.ManifestSchema.Embedded['properties'] + $props.Keys | Should -Contain 'Name' + $props.Keys | Should -Contain 'Ensure' + $props.Keys | Should -Contain 'Count' + $props.Keys | Should -Contain 'Tags' + } + + It 'Maps the Ensure enum to string type with enum values' { + $ensureProp = $resourceA.ManifestSchema.Embedded['properties']['Ensure'] + $ensureProp['type'] | Should -BeExactly 'string' + $ensureProp['enum'] | Should -Contain 'Present' + $ensureProp['enum'] | Should -Contain 'Absent' + } + + It 'Maps int property to integer type' { + $resourceA.ManifestSchema.Embedded['properties']['Count']['type'] | Should -BeExactly 'integer' + } + + It 'Maps string[] property to array type with string items' { + $tagsProp = $resourceA.ManifestSchema.Embedded['properties']['Tags'] + $tagsProp['type'] | Should -BeExactly 'array' + $tagsProp['items']['type'] | Should -BeExactly 'string' + } + + It 'Has Key property Name as required' { + $resourceA.ManifestSchema.Embedded['required'] | Should -Contain 'Name' + } + } + + Context 'ResourceB - whatIf capability and hashtable property' { + + BeforeAll { + $resourceB = $results | Where-Object { $_.Type -eq 'MultiResource/ResourceB' } + } + + It 'Detects get, set, test, and whatIf capabilities' { + $resourceB.Capabilities | Should -Contain 'get' + $resourceB.Capabilities | Should -Contain 'set' + $resourceB.Capabilities | Should -Contain 'test' + $resourceB.Capabilities | Should -Contain 'whatIf' + } + + It 'Does not include delete or export capabilities' { + $resourceB.Capabilities | Should -Not -Contain 'delete' + $resourceB.Capabilities | Should -Not -Contain 'export' + } + + It 'Maps hashtable property to object type' { + $resourceB.ManifestSchema.Embedded['properties']['Settings']['type'] | Should -BeExactly 'object' + } + } + } + + Context 'Standalone .ps1 file with a DSC resource' { + + BeforeAll { + $ps1Path = Join-Path $fixturesPath 'StandaloneResource.ps1' + $result = New-DscAdaptedResourceManifest -Path $ps1Path + } + + It 'Returns a manifest object' { + $result | Should -HaveCount 1 + } + + It 'Uses the file name as the module name' { + $result.Type | Should -BeExactly 'StandaloneResource/StandaloneResource' + } + + It 'Defaults version to 0.0.1 when no psd1 exists' { + $result.Version | Should -BeExactly '0.0.1' + } + + It 'Defaults author to empty string when no psd1 exists' { + $result.Author | Should -BeExactly '' + } + + It 'Sets path to the actual script file' { + $result.Path | Should -BeExactly 'StandaloneResource.ps1' + } + } + + Context 'File with no DSC resources' { + + It 'Emits a warning and returns nothing' { + $psm1Path = Join-Path $fixturesPath 'NoDscResource.psm1' + $result = New-DscAdaptedResourceManifest -Path $psm1Path -WarningVariable warn -WarningAction SilentlyContinue + $result | Should -BeNullOrEmpty + $warn | Should -Not -BeNullOrEmpty + $warn[0] | Should -BeLike '*No class-based DSC resources found*' + } + } + + Context 'ToJson serialization' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $json = $manifest.ToJson() + $parsed = $json | ConvertFrom-Json + } + + It 'Produces valid JSON' { + { $json | ConvertFrom-Json } | Should -Not -Throw + } + + It 'Contains the $schema key' { + $parsed.'$schema' | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + } + + It 'Contains the type key' { + $parsed.type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Contains the kind key' { + $parsed.kind | Should -BeExactly 'resource' + } + + It 'Contains the version key' { + $parsed.version | Should -BeExactly '1.0.0' + } + + It 'Contains the requireAdapter key' { + $parsed.requireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + } + + It 'Contains the schema.embedded object with properties' { + $parsed.schema.embedded | Should -Not -BeNullOrEmpty + $parsed.schema.embedded.properties | Should -Not -BeNullOrEmpty + } + } + + Context 'Pipeline input' { + + It 'Accepts Path from pipeline by value' { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $result = $psd1 | New-DscAdaptedResourceManifest + $result | Should -HaveCount 1 + $result.Type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Accepts multiple paths from pipeline' { + $paths = @( + (Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1') + (Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1') + ) + $results = $paths | New-DscAdaptedResourceManifest + $results | Should -HaveCount 3 # 1 from Simple + 2 from Multi + } + + It 'Accepts FileInfo objects from Get-ChildItem via pipeline' { + $results = Get-ChildItem -Path $fixturesPath -Filter '*.psd1' -Recurse | New-DscAdaptedResourceManifest + $results | Should -HaveCount 9 + } + } + + Context 'Input via .psm1 path resolves co-located .psd1' { + + It 'Uses psd1 metadata when psm1 is provided and psd1 exists' { + $psm1Path = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psm1' + $result = New-DscAdaptedResourceManifest -Path $psm1Path + $result.Version | Should -BeExactly '1.0.0' + $result.Author | Should -BeExactly 'Microsoft' + } + } + + Context 'Parameter validation' { + + It 'Throws when path does not exist' { + { New-DscAdaptedResourceManifest -Path 'C:\NonExistent\Fake.psd1' } | Should -Throw '*does not exist*' + } + + It 'Throws when path has an unsupported extension' { + $txtFile = Join-Path $TestDrive 'test.txt' + Set-Content -Path $txtFile -Value 'not a ps file' + { New-DscAdaptedResourceManifest -Path $txtFile } | Should -Throw '*must be a .ps1, .psm1, or .psd1 file*' + } + + It 'Is a mandatory parameter' { + (Get-Command New-DscAdaptedResourceManifest).Parameters['Path'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | + ForEach-Object { $_.Mandatory | Should -BeTrue } + } + } + + Context 'Schema additionalProperties' { + + It 'Sets additionalProperties to false' { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $result = New-DscAdaptedResourceManifest -Path $psd1 + $result.ManifestSchema.Embedded['additionalProperties'] | Should -BeFalse + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 new file mode 100644 index 000000000..a94ce661e --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/New-DscResourceManifest.Tests.ps1 @@ -0,0 +1,319 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'New-DscResourceManifest' { + + BeforeAll { + $modulePath = Join-Path (Join-Path $PSScriptRoot '..') 'Microsoft.PowerShell.DSC.psd1' + Import-Module $modulePath -Force + + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'With adapted resources from pipeline' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + $result = $adapted | New-DscResourceManifest + } + + It 'Returns a DscResourceManifestList object' { + $result.GetType().Name | Should -BeExactly 'DscResourceManifestList' + } + + It 'Contains one adapted resource' { + $result.AdaptedResources | Should -HaveCount 1 + } + + It 'Has no command-based resources' { + $result.Resources | Should -HaveCount 0 + } + + It 'Adapted resource has the correct type' { + $result.AdaptedResources[0]['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Adapted resource has the correct schema URI' { + $result.AdaptedResources[0]['$schema'] | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + } + } + + Context 'With multiple adapted resources from pipeline' { + + BeforeAll { + $paths = @( + (Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1') + (Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1') + ) + $adapted = $paths | New-DscAdaptedResourceManifest + $result = $adapted | New-DscResourceManifest + } + + It 'Contains three adapted resources' { + $result.AdaptedResources | Should -HaveCount 3 + } + + It 'Includes all resource types' { + $types = $result.AdaptedResources | ForEach-Object { $_['type'] } + $types | Should -Contain 'SimpleResource/SimpleResource' + $types | Should -Contain 'MultiResource/ResourceA' + $types | Should -Contain 'MultiResource/ResourceB' + } + } + + Context 'With AdaptedResource parameter' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + $result = New-DscResourceManifest -AdaptedResource $adapted + } + + It 'Returns a DscResourceManifestList object' { + $result.GetType().Name | Should -BeExactly 'DscResourceManifestList' + } + + It 'Contains one adapted resource' { + $result.AdaptedResources | Should -HaveCount 1 + } + } + + Context 'With command-based Resource parameter' { + + BeforeAll { + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + set = @{ + executable = 'mytool' + args = @('set') + implementsPretest = $false + return = 'state' + } + test = @{ + executable = 'mytool' + args = @('test') + return = 'state' + } + exitCodes = @{ '0' = 'Success'; '1' = 'Error' } + schema = @{ + command = @{ + executable = 'mytool' + args = @('schema') + } + } + } + $result = New-DscResourceManifest -Resource $resource + } + + It 'Returns a DscResourceManifestList object' { + $result.GetType().Name | Should -BeExactly 'DscResourceManifestList' + } + + It 'Has no adapted resources' { + $result.AdaptedResources | Should -HaveCount 0 + } + + It 'Contains one command-based resource' { + $result.Resources | Should -HaveCount 1 + } + + It 'Resource has the correct type' { + $result.Resources[0]['type'] | Should -BeExactly 'MyCompany/MyTool' + } + + It 'Resource has the correct schema URI' { + $result.Resources[0]['$schema'] | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + } + + It 'Resource has get method defined' { + $result.Resources[0]['get'] | Should -Not -BeNullOrEmpty + $result.Resources[0]['get']['executable'] | Should -BeExactly 'mytool' + } + } + + Context 'Combining adapted and command-based resources' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + } + $result = $adapted | New-DscResourceManifest -Resource $resource + } + + It 'Contains one adapted resource' { + $result.AdaptedResources | Should -HaveCount 1 + } + + It 'Contains one command-based resource' { + $result.Resources | Should -HaveCount 1 + } + + It 'Adapted resource has the correct type' { + $result.AdaptedResources[0]['type'] | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Command-based resource has the correct type' { + $result.Resources[0]['type'] | Should -BeExactly 'MyCompany/MyTool' + } + } + + Context 'Multiple command-based resources' { + + BeforeAll { + $resources = @( + @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/ToolA' + version = '1.0.0' + get = @{ executable = 'toolA'; args = @('get') } + } + @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/ToolB' + version = '2.0.0' + get = @{ executable = 'toolB'; args = @('get') } + } + ) + $result = New-DscResourceManifest -Resource $resources + } + + It 'Contains two command-based resources' { + $result.Resources | Should -HaveCount 2 + } + + It 'Includes both resource types' { + $types = $result.Resources | ForEach-Object { $_['type'] } + $types | Should -Contain 'MyCompany/ToolA' + $types | Should -Contain 'MyCompany/ToolB' + } + } + + Context 'ToJson serialization' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + } + $manifestList = $adapted | New-DscResourceManifest -Resource $resource + $json = $manifestList.ToJson() + $parsed = $json | ConvertFrom-Json + } + + It 'Produces valid JSON' { + { $json | ConvertFrom-Json } | Should -Not -Throw + } + + It 'Contains adaptedResources array' { + $parsed.adaptedResources | Should -Not -BeNullOrEmpty + $parsed.adaptedResources | Should -HaveCount 1 + } + + It 'Contains resources array' { + $parsed.resources | Should -Not -BeNullOrEmpty + $parsed.resources | Should -HaveCount 1 + } + + It 'Adapted resource in JSON has correct type' { + $parsed.adaptedResources[0].type | Should -BeExactly 'SimpleResource/SimpleResource' + } + + It 'Command resource in JSON has correct type' { + $parsed.resources[0].type | Should -BeExactly 'MyCompany/MyTool' + } + + It 'Adapted resource schema is embedded in JSON' { + $parsed.adaptedResources[0].schema.embedded | Should -Not -BeNullOrEmpty + } + } + + Context 'ToJson with only adapted resources' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $adapted = New-DscAdaptedResourceManifest -Path $psd1 + $manifestList = $adapted | New-DscResourceManifest + $json = $manifestList.ToJson() + $parsed = $json | ConvertFrom-Json + } + + It 'Contains adaptedResources array' { + $parsed.adaptedResources | Should -Not -BeNullOrEmpty + } + + It 'Does not contain resources key when empty' { + $parsed.PSObject.Properties.Name | Should -Not -Contain 'resources' + } + } + + Context 'ToJson with only command-based resources' { + + BeforeAll { + $resource = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'MyCompany/MyTool' + version = '1.0.0' + get = @{ executable = 'mytool'; args = @('get') } + } + $manifestList = New-DscResourceManifest -Resource $resource + $json = $manifestList.ToJson() + $parsed = $json | ConvertFrom-Json + } + + It 'Contains resources array' { + $parsed.resources | Should -Not -BeNullOrEmpty + } + + It 'Does not contain adaptedResources key when empty' { + $parsed.PSObject.Properties.Name | Should -Not -Contain 'adaptedResources' + } + } + + Context 'No inputs' { + + It 'Returns an empty manifest list when called without arguments' { + $result = New-DscResourceManifest + $result.AdaptedResources | Should -HaveCount 0 + $result.Resources | Should -HaveCount 0 + } + + It 'Empty manifest list produces empty JSON object' { + $result = New-DscResourceManifest + $json = $result.ToJson() + $parsed = $json | ConvertFrom-Json + $parsed.PSObject.Properties.Name | Should -Not -Contain 'adaptedResources' + $parsed.PSObject.Properties.Name | Should -Not -Contain 'resources' + } + } + + Context 'End-to-end pipeline from module to manifests file' { + + It 'Produces valid JSON matching the ManifestList schema structure' { + $psd1 = Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1' + $json = New-DscAdaptedResourceManifest -Path $psd1 | + New-DscResourceManifest | + ForEach-Object { $_.ToJson() } + + $parsed = $json | ConvertFrom-Json + $parsed.adaptedResources | Should -HaveCount 2 + $parsed.adaptedResources[0].type | Should -Not -BeNullOrEmpty + $parsed.adaptedResources[0].requireAdapter | Should -BeExactly 'Microsoft.Adapter/PowerShell' + $parsed.adaptedResources[0].schema.embedded | Should -Not -BeNullOrEmpty + } + } +} diff --git a/tools/Microsoft.PowerShell.DSC/Tests/Update-DscAdaptedResourceManifest.Tests.ps1 b/tools/Microsoft.PowerShell.DSC/Tests/Update-DscAdaptedResourceManifest.Tests.ps1 new file mode 100644 index 000000000..87672f288 --- /dev/null +++ b/tools/Microsoft.PowerShell.DSC/Tests/Update-DscAdaptedResourceManifest.Tests.ps1 @@ -0,0 +1,324 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +using module ..\Microsoft.PowerShell.DSC.psd1 + +Describe 'Update-DscAdaptedResourceManifest' { + + BeforeAll { + $fixturesPath = Join-Path $PSScriptRoot 'Fixtures' + } + + Context 'Override property description' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $override = [DscPropertyOverride]@{ + Name = 'Name' + Description = 'The unique name identifying this resource instance.' + } + $result = $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $override + } + + It 'Returns a DscAdaptedResourceManifest object' { + $result.GetType().Name | Should -BeExactly 'DscAdaptedResourceManifest' + } + + It 'Updates the property description' { + $result.ManifestSchema.Embedded['properties']['Name']['description'] | + Should -BeExactly 'The unique name identifying this resource instance.' + } + + It 'Does not modify other properties' { + $result.ManifestSchema.Embedded['properties']['Value']['description'] | + Should -BeExactly 'The Value property.' + } + + It 'Preserves the property type' { + $result.ManifestSchema.Embedded['properties']['Name']['type'] | + Should -BeExactly 'string' + } + } + + Context 'Override property title' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $override = [DscPropertyOverride]@{ + Name = 'Name' + Title = 'Resource Name' + } + $result = $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $override + } + + It 'Updates the property title' { + $result.ManifestSchema.Embedded['properties']['Name']['title'] | + Should -BeExactly 'Resource Name' + } + } + + Context 'Add JSON schema keywords' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1' + $manifests = @(New-DscAdaptedResourceManifest -Path $psd1) + $resourceA = $manifests | Where-Object { $_.Type -eq 'MultiResource/ResourceA' } + $override = [DscPropertyOverride]@{ + Name = 'Count' + JsonSchema = @{ minimum = 0; maximum = 100; default = 1 } + } + $result = $resourceA | Update-DscAdaptedResourceManifest -PropertyOverride $override + } + + It 'Adds the minimum keyword' { + $result.ManifestSchema.Embedded['properties']['Count']['minimum'] | + Should -BeExactly 0 + } + + It 'Adds the maximum keyword' { + $result.ManifestSchema.Embedded['properties']['Count']['maximum'] | + Should -BeExactly 100 + } + + It 'Adds the default keyword' { + $result.ManifestSchema.Embedded['properties']['Count']['default'] | + Should -BeExactly 1 + } + + It 'Preserves the original type' { + $result.ManifestSchema.Embedded['properties']['Count']['type'] | + Should -BeExactly 'integer' + } + } + + Context 'Replace enum with anyOf using RemoveKeys' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1' + $manifests = @(New-DscAdaptedResourceManifest -Path $psd1) + $resourceA = $manifests | Where-Object { $_.Type -eq 'MultiResource/ResourceA' } + $override = [DscPropertyOverride]@{ + Name = 'Ensure' + RemoveKeys = @('type', 'enum') + JsonSchema = @{ + anyOf = @( + @{ type = 'string'; enum = @('Present', 'Absent') } + @{ type = 'integer'; minimum = 0; maximum = 1 } + ) + } + } + $result = $resourceA | Update-DscAdaptedResourceManifest -PropertyOverride $override + } + + It 'Removes the type key' { + $result.ManifestSchema.Embedded['properties']['Ensure'].Contains('type') | + Should -BeFalse + } + + It 'Removes the enum key' { + $result.ManifestSchema.Embedded['properties']['Ensure'].Contains('enum') | + Should -BeFalse + } + + It 'Adds the anyOf keyword' { + $result.ManifestSchema.Embedded['properties']['Ensure']['anyOf'] | + Should -HaveCount 2 + } + + It 'First anyOf option is string with enum' { + $first = $result.ManifestSchema.Embedded['properties']['Ensure']['anyOf'][0] + $first['type'] | Should -BeExactly 'string' + $first['enum'] | Should -Contain 'Present' + $first['enum'] | Should -Contain 'Absent' + } + + It 'Second anyOf option is integer with range' { + $second = $result.ManifestSchema.Embedded['properties']['Ensure']['anyOf'][1] + $second['type'] | Should -BeExactly 'integer' + $second['minimum'] | Should -BeExactly 0 + $second['maximum'] | Should -BeExactly 1 + } + + It 'Preserves the title and description' { + $result.ManifestSchema.Embedded['properties']['Ensure']['title'] | + Should -BeExactly 'Ensure' + $result.ManifestSchema.Embedded['properties']['Ensure']['description'] | + Should -Not -BeNullOrEmpty + } + } + + Context 'Override required status' { + + It 'Adds a property to the required list' { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $override = [DscPropertyOverride]@{ + Name = 'Enabled' + Required = $true + } + $result = $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $override + $result.ManifestSchema.Embedded['required'] | Should -Contain 'Enabled' + } + + It 'Removes a property from the required list' { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $override = [DscPropertyOverride]@{ + Name = 'Name' + Required = $false + } + $result = $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $override + $result.ManifestSchema.Embedded['required'] | Should -Not -Contain 'Name' + } + + It 'Does not duplicate a property already in the required list' { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $override = [DscPropertyOverride]@{ + Name = 'Name' + Required = $true + } + $result = $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $override + $count = ($result.ManifestSchema.Embedded['required'] | Where-Object { $_ -eq 'Name' }).Count + $count | Should -BeExactly 1 + } + } + + Context 'Override resource-level description' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $result = $manifest | Update-DscAdaptedResourceManifest -Description 'A custom resource description.' + } + + It 'Updates the manifest description' { + $result.Description | Should -BeExactly 'A custom resource description.' + } + + It 'Updates the embedded schema description' { + $result.ManifestSchema.Embedded['description'] | + Should -BeExactly 'A custom resource description.' + } + } + + Context 'Multiple property overrides' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $overrides = @( + [DscPropertyOverride]@{ + Name = 'Name' + Description = 'The unique resource identifier.' + } + [DscPropertyOverride]@{ + Name = 'Value' + Description = 'The configuration value to apply.' + JsonSchema = @{ default = '' } + } + [DscPropertyOverride]@{ + Name = 'Enabled' + Description = 'Whether this resource instance is active.' + Required = $true + } + ) + $result = $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $overrides + } + + It 'Updates Name description' { + $result.ManifestSchema.Embedded['properties']['Name']['description'] | + Should -BeExactly 'The unique resource identifier.' + } + + It 'Updates Value description and adds default' { + $result.ManifestSchema.Embedded['properties']['Value']['description'] | + Should -BeExactly 'The configuration value to apply.' + $result.ManifestSchema.Embedded['properties']['Value']['default'] | + Should -BeExactly '' + } + + It 'Updates Enabled description and required status' { + $result.ManifestSchema.Embedded['properties']['Enabled']['description'] | + Should -BeExactly 'Whether this resource instance is active.' + $result.ManifestSchema.Embedded['required'] | Should -Contain 'Enabled' + } + } + + Context 'Warning for non-existent property' { + + It 'Emits a warning and skips the override' { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $override = [DscPropertyOverride]@{ + Name = 'DoesNotExist' + Description = 'Should warn' + } + $result = $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $override ` + -WarningVariable warn -WarningAction SilentlyContinue + $warn | Should -Not -BeNullOrEmpty + $warn[0] | Should -BeLike "*Property 'DoesNotExist' not found*" + } + } + + Context 'No-op when no overrides provided' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $originalJson = $manifest.ToJson() + $result = $manifest | Update-DscAdaptedResourceManifest + } + + It 'Returns the same object' { + $result | Should -Be $manifest + } + + It 'Does not modify the manifest' { + $result.ToJson() | Should -BeExactly $originalJson + } + } + + Context 'Pipeline support' { + + It 'Processes multiple manifests from pipeline' { + $psd1 = Join-Path $fixturesPath 'MultiResource' 'MultiResource.psd1' + $override = [DscPropertyOverride]@{ + Name = 'Name' + Description = 'Overridden name description.' + } + $results = New-DscAdaptedResourceManifest -Path $psd1 | + Update-DscAdaptedResourceManifest -PropertyOverride $override -WarningAction SilentlyContinue + # ResourceA has Name, ResourceB does not (it has Id) + $resourceA = $results | Where-Object { $_.Type -eq 'MultiResource/ResourceA' } + $resourceA.ManifestSchema.Embedded['properties']['Name']['description'] | + Should -BeExactly 'Overridden name description.' + } + } + + Context 'Serialization after update' { + + BeforeAll { + $psd1 = Join-Path $fixturesPath 'SimpleResource' 'SimpleResource.psd1' + $manifest = New-DscAdaptedResourceManifest -Path $psd1 + $override = [DscPropertyOverride]@{ + Name = 'Name' + Description = 'Custom description for serialization test.' + } + $result = $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $override + $json = $result.ToJson() + $parsed = $json | ConvertFrom-Json + } + + It 'Produces valid JSON' { + { $json | ConvertFrom-Json } | Should -Not -Throw + } + + It 'Serialized JSON contains the updated description' { + $parsed.schema.embedded.properties.Name.description | + Should -BeExactly 'Custom description for serialization test.' + } + } +}