Skip to content
Open
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
12 changes: 12 additions & 0 deletions src/Main.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,18 @@ function Invoke-Pester {

& $SafeCommands['Get-Variable'] 'Configuration' -Scope Local | Remove-Variable

# Keys from the configuration hashtable that match no section or option. They are
# reported and not thrown on, because a hashtable may carry keys meant for something
# else, but a misspelled option would otherwise leave the run on the default with
# nothing to notice (#2975). A value the option cannot use throws instead, when the
# configuration is built, because that is never intentional.
$unknownConfigurationKeys = $PesterPreference.GetUnknownKeys()
if (0 -lt $unknownConfigurationKeys.Count) {
$quotedKeys = @(foreach ($unknownKey in $unknownConfigurationKeys) { "'$unknownKey'" }) -join ', '
$reason = if (1 -eq $unknownConfigurationKeys.Count) { "key $quotedKeys, there is no such option" } else { "keys $quotedKeys, there are no such options" }
& $SafeCommands['Write-Warning'] "Ignoring configuration $reason. Check the spelling, 'Get-Help about_PesterConfiguration' lists all the options."
}

Resolve-AutoEnabledConfiguration -PesterPreference $PesterPreference

# $sessionState = Set-SessionStateHint -PassThru -Hint "Caller - Captured in Invoke-Pester" -SessionState $PSCmdlet.SessionState
Expand Down
17 changes: 17 additions & 0 deletions src/csharp/Pester/ConfigurationSection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// to have in "type accelerator" form, but without the hassle of actually adding it as a type accelerator
// that way you can easily do `[PesterConfiguration]::Default` and then inspect it, or cast a hashtable to it

using System.Collections.Generic;
using System.Reflection;

namespace Pester
Expand All @@ -32,6 +33,22 @@ public override string ToString()
return _description;
}

/// <summary>
/// Names of the options this section has. A method and not a property so it stays out of the
/// section's console output, which lists the options and their documentation.
/// </summary>
public string[] GetOptionNames()
{
var names = new List<string>();
foreach (var property in GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (typeof(Option).IsAssignableFrom(property.PropertyType))
names.Add(property.Name);
}

return names.ToArray();
}

/// <summary>
/// If this section has an Enabled option that was not explicitly modified,
/// and any other option in the section was modified, auto-enable the section.
Expand Down
14 changes: 14 additions & 0 deletions src/csharp/Pester/ConfigurationValueException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;

namespace Pester
{
/// <summary>
/// Thrown when a configuration key holds a value the option cannot use, for example a string
/// where a bool is expected. Its own type so PesterConfiguration can recognize it while
/// building the sections and prefix the message with the section name.
/// </summary>
public class ConfigurationValueException : ArgumentException
{
public ConfigurationValueException(string message) : base(message) { }
}
}
97 changes: 85 additions & 12 deletions src/csharp/Pester/DictionaryExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,47 +24,120 @@ namespace Pester
{
internal static class DictionaryExtensions
{
// A value we cannot use is never intentional, so we say so instead of leaving the option on
// its default and letting the run behave as if the option was never set (#2975). A key that
// is present but null keeps meaning "not set", because that is how it has always worked and
// it is how an unset variable arrives here (#2219).
private static ConfigurationValueException NotUsable(string key, object value, string expected)
{
return new ConfigurationValueException($"{key} expects {expected}, but got {Describe(value)}.");
}

// Name the type the way it is written in PowerShell, and show the value itself only when it
// is something worth printing. 'the string 'yes'' helps, 'the hashtable
// 'System.Collections.Hashtable'' does not.
private static string Describe(object value)
{
if (value is PSObject pso)
value = pso.BaseObject;

if (value == null)
return "nothing";

var name = TypeName(value.GetType());
return value is string || value.GetType().IsPrimitive || value is decimal
? $"the {name} '{value}'"
: $"a {name}";
}

private static string TypeName(Type type)
{
if (type == typeof(bool)) return "bool";
if (type == typeof(int)) return "int";
if (type == typeof(decimal)) return "decimal";
if (type == typeof(string)) return "string";
if (type == typeof(ScriptBlock)) return "scriptblock";
if (type == typeof(Hashtable)) return "hashtable";
if (type == typeof(ContainerInfo)) return "container";
return type.Name;
}

private static string ExpectedValue(Type type)
{
var name = TypeName(type);
return name == "int" ? "an int" : $"a {name}";
}

private static string ExpectedArray(Type type)
{
if (type == typeof(string)) return "an array of strings";
if (type == typeof(ScriptBlock)) return "an array of scriptblocks";
if (type == typeof(ContainerInfo)) return "an array of containers";
return $"an array of {TypeName(type)}";
}

public static T? GetValueOrNull<T>(this IDictionary dictionary, string key) where T : struct
{
if (!dictionary.Contains(key))
return null;

var value = dictionary[key];
if (value is null)
return null;

if (value is PSObject unwrapped)
value = unwrapped.BaseObject;

if (typeof(T) == typeof(decimal))
{
if (value is int or double)
return (T)Convert.ChangeType(value, typeof(decimal));
}

return value as T?;
var converted = value as T?;
if (converted == null)
throw NotUsable(key, value, ExpectedValue(typeof(T)));

return converted;
}

public static T GetObjectOrNull<T>(this IDictionary dictionary, string key) where T : class
{
if (!dictionary.Contains(key))
return null;

var value = dictionary[key];
if (value is null)
return null;

if (typeof(T) == typeof(string))
if (dictionary[key] is PSObject o)
if (value is PSObject o)
return (T) Convert.ChangeType(o.ToString(), typeof(string));

return dictionary[key] as T;
var converted = value as T;
if (converted == null)
throw NotUsable(key, value, ExpectedValue(typeof(T)));

return converted;
}

public static IDictionary GetIDictionaryOrNull(this IDictionary dictionary, string key)
{
if (!dictionary.Contains(key))
return null;

if (dictionary[key] is PSObject pso)
{
return pso.BaseObject as IDictionary;
}
else
{
return dictionary[key] as IDictionary;
}
var value = dictionary[key];
if (value is null)
return null;

if (value is PSObject pso)
value = pso.BaseObject;

var converted = value as IDictionary;
if (converted == null)
throw NotUsable(key, value, "a dictionary of options");

return converted;
}

public static T[] GetArrayOrNull<T>(this IDictionary dictionary, string key) where T : class
Expand Down Expand Up @@ -120,7 +193,7 @@ public static T[] GetArrayOrNull<T>(this IDictionary dictionary, string key) whe
return new T[] { (T)value };
}

return null;
throw NotUsable(key, value, ExpectedArray(typeof(T)));
}

public static void AssignValueIfNotNull<T>(this IDictionary dictionary, string key, Action<T> assign)
Expand Down
129 changes: 119 additions & 10 deletions src/csharp/Pester/PesterConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using Pester;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Management.Automation;
using System.Reflection;

// those types implement Pester configuration in a way that allows it to show information about each item
// in the powershell console without making it difficult to use. there are two tricks being used:
Expand Down Expand Up @@ -36,6 +40,7 @@ public static PesterConfiguration ShallowClone(PesterConfiguration configuration
cfg.TestDrive = TestDriveConfiguration.ShallowClone(configuration.TestDrive);
cfg.TestRegistry = TestRegistryConfiguration.ShallowClone(configuration.TestRegistry);
cfg.Mock = MockConfiguration.ShallowClone(configuration.Mock);
cfg._unknownKeys = configuration._unknownKeys;
return cfg;
}

Expand All @@ -52,24 +57,128 @@ public static PesterConfiguration Merge(PesterConfiguration configuration, Peste
cfg.TestDrive = Merger.Merge(configuration.TestDrive, @override.TestDrive);
cfg.TestRegistry = Merger.Merge(configuration.TestRegistry, @override.TestRegistry);
cfg.Mock = Merger.Merge(configuration.Mock, @override.Mock);
// Invoke-Pester merges onto the default configuration before it reports anything, so the
// unknown keys have to survive the merge or the warning is lost.
var unknown = new List<string>(configuration._unknownKeys);
foreach (var key in @override._unknownKeys)
{
if (!unknown.Contains(key))
unknown.Add(key);
}
cfg._unknownKeys = unknown.ToArray();
return cfg;
}

public PesterConfiguration(IDictionary configuration)
{
if (configuration != null)
{
Run = new RunConfiguration(configuration.GetIDictionaryOrNull(nameof(Run)));
Filter = new FilterConfiguration(configuration.GetIDictionaryOrNull(nameof(Filter)));
CodeCoverage = new CodeCoverageConfiguration(configuration.GetIDictionaryOrNull(nameof(CodeCoverage)));
TestResult = new TestResultConfiguration(configuration.GetIDictionaryOrNull(nameof(TestResult)));
Should = new ShouldConfiguration(configuration.GetIDictionaryOrNull(nameof(Should)));
Debug = new DebugConfiguration(configuration.GetIDictionaryOrNull(nameof(Debug)));
Output = new OutputConfiguration(configuration.GetIDictionaryOrNull(nameof(Output)));
TestDrive = new TestDriveConfiguration(configuration.GetIDictionaryOrNull(nameof(TestDrive)));
TestRegistry = new TestRegistryConfiguration(configuration.GetIDictionaryOrNull(nameof(TestRegistry)));
Mock = new MockConfiguration(configuration.GetIDictionaryOrNull(nameof(Mock)));
Run = Section(configuration, nameof(Run), d => new RunConfiguration(d));
Filter = Section(configuration, nameof(Filter), d => new FilterConfiguration(d));
CodeCoverage = Section(configuration, nameof(CodeCoverage), d => new CodeCoverageConfiguration(d));
TestResult = Section(configuration, nameof(TestResult), d => new TestResultConfiguration(d));
Should = Section(configuration, nameof(Should), d => new ShouldConfiguration(d));
Debug = Section(configuration, nameof(Debug), d => new DebugConfiguration(d));
Output = Section(configuration, nameof(Output), d => new OutputConfiguration(d));
TestDrive = Section(configuration, nameof(TestDrive), d => new TestDriveConfiguration(d));
TestRegistry = Section(configuration, nameof(TestRegistry), d => new TestRegistryConfiguration(d));
Mock = Section(configuration, nameof(Mock), d => new MockConfiguration(d));

_unknownKeys = CollectUnknownKeys(configuration);
}
}

// Build one section, and put the section name in front of the message when the section rejects
// a value, so the user is told 'Run.Parallel expects ...' and not just 'Parallel expects ...'.
private static T Section<T>(IDictionary configuration, string name, Func<IDictionary, T> create)
where T : ConfigurationSection
{
// Resolved outside the try, its own message already names the section.
var options = configuration.GetIDictionaryOrNull(name);
try
{
return create(options);
}
catch (ConfigurationValueException e)
{
throw new ConfigurationValueException($"{name}.{e.Message}");
}
}

// Keys the configuration does not have an option for. They are collected rather than thrown on,
// because a hashtable may legitimately be shared with something else, and reported by the caller
// (Invoke-Pester warns) so a misspelled option does not quietly do nothing (#2975).
private static string[] CollectUnknownKeys(IDictionary configuration)
{
var unknown = new List<string>();
var sections = new PesterConfiguration();

foreach (var key in configuration.Keys)
{
var name = key as string ?? key?.ToString();
var property = Match(sections.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance), name, configuration);
if (property == null)
{
unknown.Add(name);
continue;
}

// A known section, so check the options inside it the same way.
var section = property.GetValue(sections) as ConfigurationSection;
var options = configuration[property.Name] as IDictionary
?? (configuration[property.Name] as PSObject)?.BaseObject as IDictionary;
if (section == null || options == null)
continue;

var known = section.GetOptionNames();
foreach (var optionKey in options.Keys)
{
var optionName = optionKey as string ?? optionKey?.ToString();
if (Find(known, optionName, options) == null)
unknown.Add($"{property.Name}.{optionName}");
}
}

return unknown.ToArray();
}

// A key counts as known only when looking it up by the option's own name finds it. Comparing
// case-insensitively alone is not enough: a dictionary with a case-sensitive comparer holds
// 'run' without answering to 'Run', so the value would never be read and the key is unknown.
private static PropertyInfo Match(PropertyInfo[] properties, string name, IDictionary dictionary)
{
foreach (var property in properties)
{
if (!typeof(ConfigurationSection).IsAssignableFrom(property.PropertyType))
continue;

if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase) && dictionary.Contains(property.Name))
return property;
}

return null;
}

private static string Find(string[] names, string name, IDictionary dictionary)
{
foreach (var known in names)
{
if (string.Equals(known, name, StringComparison.OrdinalIgnoreCase) && dictionary.Contains(known))
return known;
}

return null;
}

private string[] _unknownKeys = new string[0];

/// <summary>
/// Keys found in the hashtable this configuration was built from that do not match any section
/// or option. A method and not a property so it stays out of the configuration's console output.
/// </summary>
public string[] GetUnknownKeys()
{
return _unknownKeys;
}

public PesterConfiguration()
Expand Down
Loading
Loading