Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Tests/Unit/Test-FeaturesJson.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,42 @@ Describe 'Features.json' {
$feature.RequiresRestorePoint | Should -BeTrue
}
}

It 'gives every feature a category that is defined' {
# The GUI groups cards by category. A feature naming a category that does
# not exist has no column to render in. A null category is allowed: those
# are the CLI-only pseudo-features that are not shown as cards.
$categoryNames = @($script:json.Categories.Name)
$orphans = @($script:features | Where-Object { $_.Category -and $_.Category -notin $categoryNames } |
ForEach-Object { "$($_.FeatureId) -> $($_.Category)" })
$orphans | Should -BeNullOrEmpty -Because "features naming a category that is not defined: $($orphans -join '; ')"
}

It 'wires every UiGroup to real features and a real category' {
# A combobox option maps to FeatureIds. If one names a feature that does
# not exist, selecting that option applies nothing, silently. And the
# group needs a category to render in, like any card.
$featureIds = @($script:features.FeatureId)
$categoryNames = @($script:json.Categories.Name)
$problems = New-Object System.Collections.Generic.List[string]

foreach ($group in @($script:json.UiGroups)) {
if ($group.Category -notin $categoryNames) {
$problems.Add("group $($group.GroupId) -> undefined category $($group.Category)")
}
foreach ($value in @($group.Values)) {
foreach ($featureId in @($value.FeatureIds)) {
if ($featureId -notin $featureIds) {
$problems.Add("group $($group.GroupId) -> undefined feature $featureId")
}
elseif ($featureId -notin $script:entryParameterNames) {
$problems.Add("group $($group.GroupId) feature $featureId has no CLI parameter")
}
}
}
}

@($script:json.UiGroups).Count | Should -BeGreaterThan 0 -Because 'the config must actually define UI groups'
$problems | Should -BeNullOrEmpty -Because "UiGroup wiring problems: $($problems -join '; ')"
}
}
57 changes: 57 additions & 0 deletions Tests/Unit/Test-SafetyGuards.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,41 @@ Describe 'Winnow script loading' {
}
}
}

# For each Winnow function, its declared parameters and whether it takes
# arbitrary arguments ($args or ValueFromRemainingArguments), plus every
# named argument passed to a Winnow function anywhere in the loaded code.
$script:functionInfo = @{}
$script:paramArgCalls = New-Object System.Collections.Generic.List[object]
foreach ($relative in @('winnow.ps1') + $script:loadedScripts) {
$ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:loadRoot $relative), [ref]$null, [ref]$null)
foreach ($definition in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
$names = @()
if ($definition.Body.ParamBlock) { $names = @($definition.Body.ParamBlock.Parameters.Name.VariablePath.UserPath) }
elseif ($definition.Parameters) { $names = @($definition.Parameters.Name.VariablePath.UserPath) }
$body = $definition.Extent.Text
$script:functionInfo[$definition.Name] = [PSCustomObject]@{
Params = $names
AcceptsArgs = ($body -match '\$args\b') -or ($body -match 'ValueFromRemainingArguments')
}
}
}
foreach ($relative in @('winnow.ps1') + $script:loadedScripts) {
$ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:loadRoot $relative), [ref]$null, [ref]$null)
foreach ($call in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true)) {
$name = $call.GetCommandName()
if (-not $name -or -not $script:functionInfo.ContainsKey($name)) { continue }
foreach ($element in $call.CommandElements) {
if ($element -is [System.Management.Automation.Language.CommandParameterAst]) {
$script:paramArgCalls.Add([PSCustomObject]@{
Function = $name
Param = $element.ParameterName
Where = ('{0}:{1}' -f $relative, $call.Extent.StartLineNumber)
})
}
}
}
}
}

It 'dot-sources every script under Scripts apart from the ones kept out by design' {
Expand Down Expand Up @@ -224,4 +259,26 @@ Describe 'Winnow script loading' {
$script:commandCalls.Count | Should -BeGreaterThan 1000 -Because 'the scan must actually see the scripts'
$unresolved | Should -BeNullOrEmpty -Because "calls that resolve to nothing: $($unresolved -join '; ')"
}

It 'passes only declared parameters to Winnow functions' {
# A named argument that matches no parameter fails at run time with "A
# parameter cannot be found that matches parameter name". PowerShell
# accepts an unambiguous prefix (-Desc for -Description) and the common
# parameters, so both are allowed here. Functions that take $args are
# skipped because they accept anything.
$common = @('Verbose', 'Debug', 'ErrorAction', 'ErrorVariable', 'WarningAction', 'WarningVariable',
'InformationAction', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'WhatIf', 'Confirm')

$badArgs = @(foreach ($use in $script:paramArgCalls) {
$info = $script:functionInfo[$use.Function]
if ($info.AcceptsArgs) { continue }
$valid = @($info.Params) + $common
$lower = $use.Param.ToLowerInvariant()
$matches = @($valid | Where-Object { $_.ToLowerInvariant() -eq $lower -or $_.ToLowerInvariant().StartsWith($lower) })
if ($matches.Count -eq 0) { "$($use.Function) -$($use.Param) at $($use.Where)" }
})

$script:paramArgCalls.Count | Should -BeGreaterThan 0 -Because 'the scan must see named arguments'
$badArgs | Should -BeNullOrEmpty -Because "parameters that match nothing: $($badArgs -join '; ')"
}
}